kimaki 0.4.24 → 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.
- package/bin.js +6 -1
- package/dist/acp-client.test.js +149 -0
- package/dist/ai-tool-to-genai.js +3 -0
- package/dist/channel-management.js +14 -9
- package/dist/cli.js +148 -17
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +98 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +313 -0
- package/dist/commands/permissions.js +126 -0
- package/dist/commands/queue.js +129 -0
- package/dist/commands/resume.js +145 -0
- package/dist/commands/session.js +142 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +161 -0
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +54 -0
- package/dist/discord-bot.js +35 -32
- package/dist/discord-utils.js +81 -15
- package/dist/format-tables.js +3 -0
- package/dist/genai-worker-wrapper.js +3 -0
- package/dist/genai-worker.js +3 -0
- package/dist/genai.js +3 -0
- package/dist/interaction-handler.js +89 -695
- package/dist/logger.js +46 -5
- package/dist/markdown.js +107 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +113 -28
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +73 -16
- package/dist/session-handler.js +176 -63
- package/dist/system-message.js +7 -38
- package/dist/tools.js +3 -0
- package/dist/utils.js +3 -0
- package/dist/voice-handler.js +21 -8
- package/dist/voice.js +31 -12
- package/dist/worker-types.js +3 -0
- package/dist/xml.js +3 -0
- package/package.json +3 -3
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/ai-tool-to-genai.ts +4 -0
- package/src/channel-management.ts +24 -8
- package/src/cli.ts +163 -18
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +139 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/{fork.ts → commands/fork.ts} +40 -7
- package/src/{model-command.ts → commands/model.ts} +31 -9
- package/src/commands/permissions.ts +146 -0
- package/src/commands/queue.ts +181 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/session.ts +184 -0
- package/src/commands/share.ts +96 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +213 -0
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +65 -0
- package/src/discord-bot.ts +40 -33
- package/src/discord-utils.ts +88 -14
- package/src/format-tables.ts +4 -0
- package/src/genai-worker-wrapper.ts +4 -0
- package/src/genai-worker.ts +4 -0
- package/src/genai.ts +4 -0
- package/src/interaction-handler.ts +111 -924
- package/src/logger.ts +51 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +136 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +143 -30
- package/src/opencode.ts +84 -21
- package/src/session-handler.ts +248 -91
- package/src/system-message.ts +8 -38
- package/src/tools.ts +4 -0
- package/src/utils.ts +4 -0
- package/src/voice-handler.ts +24 -9
- package/src/voice.ts +36 -13
- package/src/worker-types.ts +4 -0
- package/src/xml.ts +4 -0
- package/README.md +0 -48
package/src/opencode.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
// OpenCode server process manager.
|
|
2
|
+
// Spawns and maintains OpenCode API servers per project directory,
|
|
3
|
+
// handles automatic restarts on failure, and provides typed SDK clients.
|
|
4
|
+
|
|
1
5
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
6
|
+
import fs from 'node:fs'
|
|
2
7
|
import net from 'node:net'
|
|
3
8
|
import {
|
|
4
9
|
createOpencodeClient,
|
|
5
10
|
type OpencodeClient,
|
|
6
11
|
type Config,
|
|
7
12
|
} from '@opencode-ai/sdk'
|
|
13
|
+
import {
|
|
14
|
+
createOpencodeClient as createOpencodeClientV2,
|
|
15
|
+
type OpencodeClient as OpencodeClientV2,
|
|
16
|
+
} from '@opencode-ai/sdk/v2'
|
|
8
17
|
import { createLogger } from './logger.js'
|
|
9
18
|
|
|
10
19
|
const opencodeLogger = createLogger('OPENCODE')
|
|
@@ -14,6 +23,7 @@ const opencodeServers = new Map<
|
|
|
14
23
|
{
|
|
15
24
|
process: ChildProcess
|
|
16
25
|
client: OpencodeClient
|
|
26
|
+
clientV2: OpencodeClientV2
|
|
17
27
|
port: number
|
|
18
28
|
}
|
|
19
29
|
>()
|
|
@@ -42,21 +52,36 @@ async function waitForServer(port: number, maxAttempts = 30): Promise<boolean> {
|
|
|
42
52
|
for (let i = 0; i < maxAttempts; i++) {
|
|
43
53
|
try {
|
|
44
54
|
const endpoints = [
|
|
45
|
-
`http://
|
|
46
|
-
`http://
|
|
47
|
-
`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`,
|
|
48
58
|
]
|
|
49
59
|
|
|
50
60
|
for (const endpoint of endpoints) {
|
|
51
61
|
try {
|
|
52
62
|
const response = await fetch(endpoint)
|
|
53
63
|
if (response.status < 500) {
|
|
54
|
-
opencodeLogger.log(`Server ready on port `)
|
|
55
64
|
return true
|
|
56
65
|
}
|
|
57
|
-
|
|
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
|
+
}
|
|
77
|
+
}
|
|
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
|
|
58
82
|
}
|
|
59
|
-
|
|
83
|
+
opencodeLogger.debug(`Server polling attempt failed: ${(e as Error).message}`)
|
|
84
|
+
}
|
|
60
85
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
61
86
|
}
|
|
62
87
|
throw new Error(
|
|
@@ -81,9 +106,17 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
81
106
|
}
|
|
82
107
|
}
|
|
83
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
|
+
|
|
84
116
|
const port = await getOpenPort()
|
|
85
117
|
|
|
86
|
-
const
|
|
118
|
+
const opencodeBinDir = `${process.env.HOME}/.opencode/bin`
|
|
119
|
+
const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`
|
|
87
120
|
|
|
88
121
|
const serverProcess = spawn(
|
|
89
122
|
opencodeCommand,
|
|
@@ -109,23 +142,24 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
109
142
|
},
|
|
110
143
|
)
|
|
111
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
|
+
|
|
112
149
|
serverProcess.stdout?.on('data', (data) => {
|
|
113
|
-
|
|
150
|
+
logBuffer.push(`[stdout] ${data.toString().trim()}`)
|
|
114
151
|
})
|
|
115
152
|
|
|
116
153
|
serverProcess.stderr?.on('data', (data) => {
|
|
117
|
-
|
|
154
|
+
logBuffer.push(`[stderr] ${data.toString().trim()}`)
|
|
118
155
|
})
|
|
119
156
|
|
|
120
157
|
serverProcess.on('error', (error) => {
|
|
121
|
-
|
|
158
|
+
logBuffer.push(`Failed to start server on port ${port}: ${error}`)
|
|
122
159
|
})
|
|
123
160
|
|
|
124
161
|
serverProcess.on('exit', (code) => {
|
|
125
|
-
opencodeLogger.log(
|
|
126
|
-
`Opencode server on ${directory} exited with code:`,
|
|
127
|
-
code,
|
|
128
|
-
)
|
|
162
|
+
opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code)
|
|
129
163
|
opencodeServers.delete(directory)
|
|
130
164
|
if (code !== 0) {
|
|
131
165
|
const retryCount = serverRetryCount.get(directory) || 0
|
|
@@ -147,20 +181,39 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
147
181
|
}
|
|
148
182
|
})
|
|
149
183
|
|
|
150
|
-
|
|
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
|
+
})
|
|
151
202
|
|
|
152
203
|
const client = createOpencodeClient({
|
|
153
|
-
baseUrl
|
|
154
|
-
fetch:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
204
|
+
baseUrl,
|
|
205
|
+
fetch: fetchWithTimeout,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const clientV2 = createOpencodeClientV2({
|
|
209
|
+
baseUrl,
|
|
210
|
+
fetch: fetchWithTimeout as typeof fetch,
|
|
159
211
|
})
|
|
160
212
|
|
|
161
213
|
opencodeServers.set(directory, {
|
|
162
214
|
process: serverProcess,
|
|
163
215
|
client,
|
|
216
|
+
clientV2,
|
|
164
217
|
port,
|
|
165
218
|
})
|
|
166
219
|
|
|
@@ -178,3 +231,13 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
178
231
|
export function getOpencodeServers() {
|
|
179
232
|
return opencodeServers
|
|
180
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
|
+
}
|
package/src/session-handler.ts
CHANGED
|
@@ -1,46 +1,136 @@
|
|
|
1
|
-
|
|
1
|
+
// OpenCode session lifecycle manager.
|
|
2
|
+
// Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
|
|
3
|
+
// Handles streaming events, permissions, abort signals, and message queuing.
|
|
4
|
+
|
|
5
|
+
import type { Part, PermissionRequest } from '@opencode-ai/sdk/v2'
|
|
6
|
+
import type { FilePartInput } from '@opencode-ai/sdk'
|
|
2
7
|
import type { Message, ThreadChannel } from 'discord.js'
|
|
3
8
|
import prettyMilliseconds from 'pretty-ms'
|
|
4
|
-
import { getDatabase, getSessionModel, getChannelModel } from './database.js'
|
|
5
|
-
import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
|
|
6
|
-
import { sendThreadMessage } from './discord-utils.js'
|
|
9
|
+
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js'
|
|
10
|
+
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
|
|
11
|
+
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js'
|
|
7
12
|
import { formatPart } from './message-formatting.js'
|
|
8
13
|
import { getOpencodeSystemMessage } from './system-message.js'
|
|
9
14
|
import { createLogger } from './logger.js'
|
|
10
15
|
import { isAbortError } from './utils.js'
|
|
16
|
+
import { showAskUserQuestionDropdowns } from './commands/ask-question.js'
|
|
11
17
|
|
|
12
18
|
const sessionLogger = createLogger('SESSION')
|
|
13
19
|
const voiceLogger = createLogger('VOICE')
|
|
14
20
|
const discordLogger = createLogger('DISCORD')
|
|
15
21
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
export const abortControllers = new Map<string, AbortController>()
|
|
23
|
+
|
|
24
|
+
export const pendingPermissions = new Map<
|
|
25
|
+
string,
|
|
26
|
+
{ permission: PermissionRequest; messageId: string; directory: string }
|
|
27
|
+
>()
|
|
28
|
+
|
|
29
|
+
export type QueuedMessage = {
|
|
30
|
+
prompt: string
|
|
31
|
+
userId: string
|
|
32
|
+
username: string
|
|
33
|
+
queuedAt: number
|
|
34
|
+
images?: FilePartInput[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Queue of messages waiting to be sent after current response finishes
|
|
38
|
+
// Key is threadId, value is array of queued messages
|
|
39
|
+
export const messageQueue = new Map<string, QueuedMessage[]>()
|
|
40
|
+
|
|
41
|
+
export function addToQueue({
|
|
42
|
+
threadId,
|
|
43
|
+
message,
|
|
44
|
+
}: {
|
|
45
|
+
threadId: string
|
|
46
|
+
message: QueuedMessage
|
|
47
|
+
}): number {
|
|
48
|
+
const queue = messageQueue.get(threadId) || []
|
|
49
|
+
queue.push(message)
|
|
50
|
+
messageQueue.set(threadId, queue)
|
|
51
|
+
return queue.length
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getQueueLength(threadId: string): number {
|
|
55
|
+
return messageQueue.get(threadId)?.length || 0
|
|
22
56
|
}
|
|
23
57
|
|
|
24
|
-
export function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
58
|
+
export function clearQueue(threadId: string): void {
|
|
59
|
+
messageQueue.delete(threadId)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Abort a running session and retry with the last user message.
|
|
64
|
+
* Used when model preference changes mid-request.
|
|
65
|
+
* Fetches last user message from OpenCode API instead of tracking in memory.
|
|
66
|
+
* @returns true if aborted and retry scheduled, false if no active request
|
|
67
|
+
*/
|
|
68
|
+
export async function abortAndRetrySession({
|
|
69
|
+
sessionId,
|
|
70
|
+
thread,
|
|
71
|
+
projectDirectory,
|
|
72
|
+
}: {
|
|
73
|
+
sessionId: string
|
|
74
|
+
thread: ThreadChannel
|
|
75
|
+
projectDirectory: string
|
|
76
|
+
}): Promise<boolean> {
|
|
77
|
+
const controller = abortControllers.get(sessionId)
|
|
78
|
+
|
|
79
|
+
if (!controller) {
|
|
80
|
+
sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`)
|
|
81
|
+
return false
|
|
28
82
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
83
|
+
|
|
84
|
+
sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`)
|
|
85
|
+
|
|
86
|
+
// Abort with special reason so we don't show "completed" message
|
|
87
|
+
controller.abort('model-change')
|
|
88
|
+
|
|
89
|
+
// Also call the API abort endpoint
|
|
90
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
91
|
+
try {
|
|
92
|
+
await getClient().session.abort({ path: { id: sessionId } })
|
|
93
|
+
} catch (e) {
|
|
94
|
+
sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e)
|
|
32
95
|
}
|
|
33
|
-
const command = match[1]!
|
|
34
|
-
const args = match[2]?.trim() || ''
|
|
35
|
-
return { isCommand: true, command, arguments: args }
|
|
36
|
-
}
|
|
37
96
|
|
|
38
|
-
|
|
97
|
+
// Small delay to let the abort propagate
|
|
98
|
+
await new Promise((resolve) => { setTimeout(resolve, 300) })
|
|
39
99
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
100
|
+
// Fetch last user message from API
|
|
101
|
+
sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`)
|
|
102
|
+
const messagesResponse = await getClient().session.messages({ path: { id: sessionId } })
|
|
103
|
+
const messages = messagesResponse.data || []
|
|
104
|
+
const lastUserMessage = [...messages].reverse().find((m) => m.info.role === 'user')
|
|
105
|
+
|
|
106
|
+
if (!lastUserMessage) {
|
|
107
|
+
sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`)
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Extract text and images from parts
|
|
112
|
+
const textPart = lastUserMessage.parts.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined
|
|
113
|
+
const prompt = textPart?.text || ''
|
|
114
|
+
const images = lastUserMessage.parts.filter((p) => p.type === 'file') as FilePartInput[]
|
|
115
|
+
|
|
116
|
+
sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`)
|
|
117
|
+
|
|
118
|
+
// Use setImmediate to avoid blocking
|
|
119
|
+
setImmediate(() => {
|
|
120
|
+
handleOpencodeSession({
|
|
121
|
+
prompt,
|
|
122
|
+
thread,
|
|
123
|
+
projectDirectory,
|
|
124
|
+
images,
|
|
125
|
+
}).catch(async (e) => {
|
|
126
|
+
sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e)
|
|
127
|
+
const errorMsg = e instanceof Error ? e.message : String(e)
|
|
128
|
+
await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
44
134
|
|
|
45
135
|
export async function handleOpencodeSession({
|
|
46
136
|
prompt,
|
|
@@ -48,16 +138,17 @@ export async function handleOpencodeSession({
|
|
|
48
138
|
projectDirectory,
|
|
49
139
|
originalMessage,
|
|
50
140
|
images = [],
|
|
51
|
-
parsedCommand,
|
|
52
141
|
channelId,
|
|
142
|
+
command,
|
|
53
143
|
}: {
|
|
54
144
|
prompt: string
|
|
55
145
|
thread: ThreadChannel
|
|
56
146
|
projectDirectory?: string
|
|
57
147
|
originalMessage?: Message
|
|
58
148
|
images?: FilePartInput[]
|
|
59
|
-
parsedCommand?: ParsedCommand
|
|
60
149
|
channelId?: string
|
|
150
|
+
/** If set, uses session.command API instead of session.prompt */
|
|
151
|
+
command?: { name: string; arguments: string }
|
|
61
152
|
}): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
|
|
62
153
|
voiceLogger.log(
|
|
63
154
|
`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
|
|
@@ -160,9 +251,15 @@ export async function handleOpencodeSession({
|
|
|
160
251
|
return
|
|
161
252
|
}
|
|
162
253
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
254
|
+
// Use v2 client for event subscription (has proper types for question.asked events)
|
|
255
|
+
const clientV2 = getOpencodeClientV2(directory)
|
|
256
|
+
if (!clientV2) {
|
|
257
|
+
throw new Error(`OpenCode v2 client not found for directory: ${directory}`)
|
|
258
|
+
}
|
|
259
|
+
const eventsResult = await clientV2.event.subscribe(
|
|
260
|
+
{ directory },
|
|
261
|
+
{ signal: abortController.signal }
|
|
262
|
+
)
|
|
166
263
|
|
|
167
264
|
if (abortController.signal.aborted) {
|
|
168
265
|
sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
|
|
@@ -183,6 +280,7 @@ export async function handleOpencodeSession({
|
|
|
183
280
|
let stopTyping: (() => void) | null = null
|
|
184
281
|
let usedModel: string | undefined
|
|
185
282
|
let usedProviderID: string | undefined
|
|
283
|
+
let usedAgent: string | undefined
|
|
186
284
|
let tokensUsedInSession = 0
|
|
187
285
|
let lastDisplayedContextPercentage = 0
|
|
188
286
|
let modelContextLimit: number | undefined
|
|
@@ -233,7 +331,7 @@ export async function handleOpencodeSession({
|
|
|
233
331
|
const sendPartMessage = async (part: Part) => {
|
|
234
332
|
const content = formatPart(part) + '\n\n'
|
|
235
333
|
if (!content.trim() || content.length === 0) {
|
|
236
|
-
discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
334
|
+
// discordLogger.log(`SKIP: Part ${part.id} has no content`)
|
|
237
335
|
return
|
|
238
336
|
}
|
|
239
337
|
|
|
@@ -276,6 +374,7 @@ export async function handleOpencodeSession({
|
|
|
276
374
|
assistantMessageId = msg.id
|
|
277
375
|
usedModel = msg.modelID
|
|
278
376
|
usedProviderID = msg.providerID
|
|
377
|
+
usedAgent = msg.mode
|
|
279
378
|
|
|
280
379
|
if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
|
|
281
380
|
if (!modelContextLimit) {
|
|
@@ -296,7 +395,7 @@ export async function handleOpencodeSession({
|
|
|
296
395
|
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
|
|
297
396
|
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
298
397
|
lastDisplayedContextPercentage = thresholdCrossed
|
|
299
|
-
await sendThreadMessage(thread,
|
|
398
|
+
await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`)
|
|
300
399
|
}
|
|
301
400
|
}
|
|
302
401
|
}
|
|
@@ -372,7 +471,7 @@ export async function handleOpencodeSession({
|
|
|
372
471
|
)
|
|
373
472
|
}
|
|
374
473
|
break
|
|
375
|
-
} else if (event.type === 'permission.
|
|
474
|
+
} else if (event.type === 'permission.asked') {
|
|
376
475
|
const permission = event.properties
|
|
377
476
|
if (permission.sessionID !== session.id) {
|
|
378
477
|
voiceLogger.log(
|
|
@@ -382,18 +481,15 @@ export async function handleOpencodeSession({
|
|
|
382
481
|
}
|
|
383
482
|
|
|
384
483
|
sessionLogger.log(
|
|
385
|
-
`Permission requested:
|
|
484
|
+
`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
|
|
386
485
|
)
|
|
387
486
|
|
|
388
|
-
const patternStr =
|
|
389
|
-
? permission.pattern.join(', ')
|
|
390
|
-
: permission.pattern || ''
|
|
487
|
+
const patternStr = permission.patterns.join(', ')
|
|
391
488
|
|
|
392
489
|
const permissionMessage = await sendThreadMessage(
|
|
393
490
|
thread,
|
|
394
491
|
`⚠️ **Permission Required**\n\n` +
|
|
395
|
-
`**Type:** \`${permission.
|
|
396
|
-
`**Action:** ${permission.title}\n` +
|
|
492
|
+
`**Type:** \`${permission.permission}\`\n` +
|
|
397
493
|
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
398
494
|
`\nUse \`/accept\` or \`/reject\` to respond.`,
|
|
399
495
|
)
|
|
@@ -404,19 +500,40 @@ export async function handleOpencodeSession({
|
|
|
404
500
|
directory,
|
|
405
501
|
})
|
|
406
502
|
} else if (event.type === 'permission.replied') {
|
|
407
|
-
const {
|
|
503
|
+
const { requestID, reply, sessionID } = event.properties
|
|
408
504
|
if (sessionID !== session.id) {
|
|
409
505
|
continue
|
|
410
506
|
}
|
|
411
507
|
|
|
412
508
|
sessionLogger.log(
|
|
413
|
-
`Permission ${
|
|
509
|
+
`Permission ${requestID} replied with: ${reply}`,
|
|
414
510
|
)
|
|
415
511
|
|
|
416
512
|
const pending = pendingPermissions.get(thread.id)
|
|
417
|
-
if (pending && pending.permission.id ===
|
|
513
|
+
if (pending && pending.permission.id === requestID) {
|
|
418
514
|
pendingPermissions.delete(thread.id)
|
|
419
515
|
}
|
|
516
|
+
} else if (event.type === 'question.asked') {
|
|
517
|
+
const questionRequest = event.properties
|
|
518
|
+
|
|
519
|
+
if (questionRequest.sessionID !== session.id) {
|
|
520
|
+
sessionLogger.log(
|
|
521
|
+
`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`,
|
|
522
|
+
)
|
|
523
|
+
continue
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
sessionLogger.log(
|
|
527
|
+
`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
await showAskUserQuestionDropdowns({
|
|
531
|
+
thread,
|
|
532
|
+
sessionId: session.id,
|
|
533
|
+
directory,
|
|
534
|
+
requestId: questionRequest.id,
|
|
535
|
+
input: { questions: questionRequest.questions },
|
|
536
|
+
})
|
|
420
537
|
}
|
|
421
538
|
}
|
|
422
539
|
} catch (e) {
|
|
@@ -453,6 +570,7 @@ export async function handleOpencodeSession({
|
|
|
453
570
|
)
|
|
454
571
|
const attachCommand = port ? ` ⋅ ${session.id}` : ''
|
|
455
572
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
|
|
573
|
+
const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : ''
|
|
456
574
|
let contextInfo = ''
|
|
457
575
|
|
|
458
576
|
try {
|
|
@@ -467,8 +585,38 @@ export async function handleOpencodeSession({
|
|
|
467
585
|
sessionLogger.error('Failed to fetch provider info for context percentage:', e)
|
|
468
586
|
}
|
|
469
587
|
|
|
470
|
-
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}
|
|
588
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS })
|
|
471
589
|
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
|
|
590
|
+
|
|
591
|
+
// Process queued messages after completion
|
|
592
|
+
const queue = messageQueue.get(thread.id)
|
|
593
|
+
if (queue && queue.length > 0) {
|
|
594
|
+
const nextMessage = queue.shift()!
|
|
595
|
+
if (queue.length === 0) {
|
|
596
|
+
messageQueue.delete(thread.id)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`)
|
|
600
|
+
|
|
601
|
+
// Show that queued message is being sent
|
|
602
|
+
await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`)
|
|
603
|
+
|
|
604
|
+
// Send the queued message as a new prompt (recursive call)
|
|
605
|
+
// Use setImmediate to avoid blocking and allow this finally to complete
|
|
606
|
+
setImmediate(() => {
|
|
607
|
+
handleOpencodeSession({
|
|
608
|
+
prompt: nextMessage.prompt,
|
|
609
|
+
thread,
|
|
610
|
+
projectDirectory,
|
|
611
|
+
images: nextMessage.images,
|
|
612
|
+
channelId,
|
|
613
|
+
}).catch(async (e) => {
|
|
614
|
+
sessionLogger.error(`[QUEUE] Failed to process queued message:`, e)
|
|
615
|
+
const errorMsg = e instanceof Error ? e.message : String(e)
|
|
616
|
+
await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`)
|
|
617
|
+
})
|
|
618
|
+
})
|
|
619
|
+
}
|
|
472
620
|
} else {
|
|
473
621
|
sessionLogger.log(
|
|
474
622
|
`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
|
|
@@ -487,56 +635,65 @@ export async function handleOpencodeSession({
|
|
|
487
635
|
|
|
488
636
|
stopTyping = startTyping()
|
|
489
637
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
body: {
|
|
498
|
-
command: parsedCommand.command,
|
|
499
|
-
arguments: parsedCommand.arguments,
|
|
500
|
-
},
|
|
501
|
-
signal: abortController.signal,
|
|
502
|
-
})
|
|
503
|
-
} else {
|
|
504
|
-
voiceLogger.log(
|
|
505
|
-
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
506
|
-
)
|
|
507
|
-
if (images.length > 0) {
|
|
508
|
-
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
|
|
638
|
+
voiceLogger.log(
|
|
639
|
+
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
640
|
+
)
|
|
641
|
+
// append image paths to prompt so ai knows where they are on disk
|
|
642
|
+
const promptWithImagePaths = (() => {
|
|
643
|
+
if (images.length === 0) {
|
|
644
|
+
return prompt
|
|
509
645
|
}
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
})
|
|
646
|
+
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
|
|
647
|
+
const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n')
|
|
648
|
+
return `${prompt}\n\n**attached images:**\n${imagePathsList}`
|
|
649
|
+
})()
|
|
650
|
+
|
|
651
|
+
const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
|
|
652
|
+
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
|
|
653
|
+
|
|
654
|
+
// Get model preference: session-level overrides channel-level
|
|
655
|
+
const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
|
|
656
|
+
const modelParam = (() => {
|
|
657
|
+
if (!modelPreference) {
|
|
658
|
+
return undefined
|
|
659
|
+
}
|
|
660
|
+
const [providerID, ...modelParts] = modelPreference.split('/')
|
|
661
|
+
const modelID = modelParts.join('/')
|
|
662
|
+
if (!providerID || !modelID) {
|
|
663
|
+
return undefined
|
|
664
|
+
}
|
|
665
|
+
sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`)
|
|
666
|
+
return { providerID, modelID }
|
|
667
|
+
})()
|
|
668
|
+
|
|
669
|
+
// Get agent preference: session-level overrides channel-level
|
|
670
|
+
const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
|
|
671
|
+
if (agentPreference) {
|
|
672
|
+
sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
|
|
538
673
|
}
|
|
539
674
|
|
|
675
|
+
// Use session.command API for slash commands, session.prompt for regular messages
|
|
676
|
+
const response = command
|
|
677
|
+
? await getClient().session.command({
|
|
678
|
+
path: { id: session.id },
|
|
679
|
+
body: {
|
|
680
|
+
command: command.name,
|
|
681
|
+
arguments: command.arguments,
|
|
682
|
+
agent: agentPreference,
|
|
683
|
+
},
|
|
684
|
+
signal: abortController.signal,
|
|
685
|
+
})
|
|
686
|
+
: await getClient().session.prompt({
|
|
687
|
+
path: { id: session.id },
|
|
688
|
+
body: {
|
|
689
|
+
parts,
|
|
690
|
+
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
691
|
+
model: modelParam,
|
|
692
|
+
agent: agentPreference,
|
|
693
|
+
},
|
|
694
|
+
signal: abortController.signal,
|
|
695
|
+
})
|
|
696
|
+
|
|
540
697
|
if (response.error) {
|
|
541
698
|
const errorMessage = (() => {
|
|
542
699
|
const err = response.error
|