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.
- package/dist/acp-client.test.js +149 -0
- package/dist/channel-management.js +11 -9
- package/dist/cli.js +59 -7
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/model.js +23 -4
- 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 +20 -0
- 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 +131 -62
- 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 +74 -8
- package/src/commands/add-project.ts +1 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/fork.ts +1 -2
- package/src/commands/model.ts +24 -4
- 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 +25 -0
- 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 +180 -90
- package/src/system-message.ts +4 -51
- package/src/voice-handler.ts +20 -9
- package/src/voice.ts +32 -13
- package/LICENSE +0 -21
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// AskUserQuestion tool handler - Shows Discord dropdowns for AI questions.
|
|
2
|
+
// When the AI uses the AskUserQuestion tool, this module renders dropdowns
|
|
3
|
+
// for each question and collects user responses.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
StringSelectMenuBuilder,
|
|
7
|
+
StringSelectMenuInteraction,
|
|
8
|
+
ActionRowBuilder,
|
|
9
|
+
type ThreadChannel,
|
|
10
|
+
} from 'discord.js'
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import { sendThreadMessage } from '../discord-utils.js'
|
|
13
|
+
import { getOpencodeServerPort } from '../opencode.js'
|
|
14
|
+
import { createLogger } from '../logger.js'
|
|
15
|
+
|
|
16
|
+
const logger = createLogger('ASK_QUESTION')
|
|
17
|
+
|
|
18
|
+
// Schema matching the question tool input
|
|
19
|
+
export type AskUserQuestionInput = {
|
|
20
|
+
questions: Array<{
|
|
21
|
+
question: string
|
|
22
|
+
header: string // max 12 chars
|
|
23
|
+
options: Array<{
|
|
24
|
+
label: string
|
|
25
|
+
description: string
|
|
26
|
+
}>
|
|
27
|
+
multiple?: boolean // optional, defaults to false
|
|
28
|
+
}>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type PendingQuestionContext = {
|
|
32
|
+
sessionId: string
|
|
33
|
+
directory: string
|
|
34
|
+
thread: ThreadChannel
|
|
35
|
+
requestId: string // OpenCode question request ID for replying
|
|
36
|
+
questions: AskUserQuestionInput['questions']
|
|
37
|
+
answers: Record<number, string[]> // questionIndex -> selected labels
|
|
38
|
+
totalQuestions: number
|
|
39
|
+
answeredCount: number
|
|
40
|
+
contextHash: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Store pending question contexts by hash
|
|
44
|
+
export const pendingQuestionContexts = new Map<string, PendingQuestionContext>()
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Show dropdown menus for question tool input.
|
|
48
|
+
* Sends one message per question with the dropdown directly under the question text.
|
|
49
|
+
*/
|
|
50
|
+
export async function showAskUserQuestionDropdowns({
|
|
51
|
+
thread,
|
|
52
|
+
sessionId,
|
|
53
|
+
directory,
|
|
54
|
+
requestId,
|
|
55
|
+
input,
|
|
56
|
+
}: {
|
|
57
|
+
thread: ThreadChannel
|
|
58
|
+
sessionId: string
|
|
59
|
+
directory: string
|
|
60
|
+
requestId: string // OpenCode question request ID
|
|
61
|
+
input: AskUserQuestionInput
|
|
62
|
+
}): Promise<void> {
|
|
63
|
+
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
64
|
+
|
|
65
|
+
const context: PendingQuestionContext = {
|
|
66
|
+
sessionId,
|
|
67
|
+
directory,
|
|
68
|
+
thread,
|
|
69
|
+
requestId,
|
|
70
|
+
questions: input.questions,
|
|
71
|
+
answers: {},
|
|
72
|
+
totalQuestions: input.questions.length,
|
|
73
|
+
answeredCount: 0,
|
|
74
|
+
contextHash,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pendingQuestionContexts.set(contextHash, context)
|
|
78
|
+
|
|
79
|
+
// Send one message per question with its dropdown directly underneath
|
|
80
|
+
for (let i = 0; i < input.questions.length; i++) {
|
|
81
|
+
const q = input.questions[i]!
|
|
82
|
+
|
|
83
|
+
// Map options to Discord select menu options
|
|
84
|
+
// Discord max: 25 options per select menu
|
|
85
|
+
const options = [
|
|
86
|
+
...q.options.slice(0, 24).map((opt, optIdx) => ({
|
|
87
|
+
label: opt.label.slice(0, 100),
|
|
88
|
+
value: `${optIdx}`,
|
|
89
|
+
description: opt.description.slice(0, 100),
|
|
90
|
+
})),
|
|
91
|
+
{
|
|
92
|
+
label: 'Other',
|
|
93
|
+
value: 'other',
|
|
94
|
+
description: 'Provide a custom answer in chat',
|
|
95
|
+
},
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
99
|
+
.setCustomId(`ask_question:${contextHash}:${i}`)
|
|
100
|
+
.setPlaceholder(`Select an option`)
|
|
101
|
+
.addOptions(options)
|
|
102
|
+
|
|
103
|
+
// Enable multi-select if the question supports it
|
|
104
|
+
if (q.multiple) {
|
|
105
|
+
selectMenu.setMinValues(1)
|
|
106
|
+
selectMenu.setMaxValues(options.length)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
110
|
+
|
|
111
|
+
await thread.send({
|
|
112
|
+
content: `**${q.header}**\n${q.question}`,
|
|
113
|
+
components: [actionRow],
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
logger.log(`Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handle dropdown selection for AskUserQuestion.
|
|
122
|
+
*/
|
|
123
|
+
export async function handleAskQuestionSelectMenu(
|
|
124
|
+
interaction: StringSelectMenuInteraction
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const customId = interaction.customId
|
|
127
|
+
|
|
128
|
+
if (!customId.startsWith('ask_question:')) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const parts = customId.split(':')
|
|
133
|
+
const contextHash = parts[1]
|
|
134
|
+
const questionIndex = parseInt(parts[2]!, 10)
|
|
135
|
+
|
|
136
|
+
if (!contextHash) {
|
|
137
|
+
await interaction.reply({
|
|
138
|
+
content: 'Invalid selection.',
|
|
139
|
+
ephemeral: true,
|
|
140
|
+
})
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const context = pendingQuestionContexts.get(contextHash)
|
|
145
|
+
|
|
146
|
+
if (!context) {
|
|
147
|
+
await interaction.reply({
|
|
148
|
+
content: 'This question has expired. Please ask the AI again.',
|
|
149
|
+
ephemeral: true,
|
|
150
|
+
})
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await interaction.deferUpdate()
|
|
155
|
+
|
|
156
|
+
const selectedValues = interaction.values
|
|
157
|
+
const question = context.questions[questionIndex]
|
|
158
|
+
|
|
159
|
+
if (!question) {
|
|
160
|
+
logger.error(`Question index ${questionIndex} not found in context`)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if "other" was selected
|
|
165
|
+
if (selectedValues.includes('other')) {
|
|
166
|
+
// User wants to provide custom answer
|
|
167
|
+
// For now, mark as "Other" - they can type in chat
|
|
168
|
+
context.answers[questionIndex] = ['Other (please type your answer in chat)']
|
|
169
|
+
} else {
|
|
170
|
+
// Map value indices back to option labels
|
|
171
|
+
context.answers[questionIndex] = selectedValues.map((v) => {
|
|
172
|
+
const optIdx = parseInt(v, 10)
|
|
173
|
+
return question.options[optIdx]?.label || `Option ${optIdx + 1}`
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
context.answeredCount++
|
|
178
|
+
|
|
179
|
+
// Update this question's message: show answer and remove dropdown
|
|
180
|
+
const answeredText = context.answers[questionIndex]!.join(', ')
|
|
181
|
+
await interaction.editReply({
|
|
182
|
+
content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
|
|
183
|
+
components: [], // Remove the dropdown
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Check if all questions are answered
|
|
187
|
+
if (context.answeredCount >= context.totalQuestions) {
|
|
188
|
+
// All questions answered - send result back to session
|
|
189
|
+
await submitQuestionAnswers(context)
|
|
190
|
+
pendingQuestionContexts.delete(contextHash)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Submit all collected answers back to the OpenCode session.
|
|
196
|
+
* Uses the question.reply API to provide answers to the waiting tool.
|
|
197
|
+
*/
|
|
198
|
+
async function submitQuestionAnswers(
|
|
199
|
+
context: PendingQuestionContext
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
try {
|
|
202
|
+
// Build answers array: each element is an array of selected labels for that question
|
|
203
|
+
const answersPayload = context.questions.map((_, i) => {
|
|
204
|
+
return context.answers[i] || []
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Reply to the question using direct HTTP call to OpenCode API
|
|
208
|
+
// (v1 SDK doesn't have question.reply, so we call it directly)
|
|
209
|
+
const port = getOpencodeServerPort(context.directory)
|
|
210
|
+
if (!port) {
|
|
211
|
+
throw new Error('OpenCode server not found for directory')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const response = await fetch(
|
|
215
|
+
`http://127.0.0.1:${port}/question/${context.requestId}/reply`,
|
|
216
|
+
{
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'Content-Type': 'application/json' },
|
|
219
|
+
body: JSON.stringify({ answers: answersPayload }),
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
const text = await response.text()
|
|
225
|
+
throw new Error(`Failed to reply to question: ${response.status} ${text}`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`)
|
|
229
|
+
} catch (error) {
|
|
230
|
+
logger.error('Failed to submit answers:', error)
|
|
231
|
+
await sendThreadMessage(
|
|
232
|
+
context.thread,
|
|
233
|
+
`✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check if a tool part is an AskUserQuestion tool.
|
|
240
|
+
* Returns the parsed input if valid, null otherwise.
|
|
241
|
+
*/
|
|
242
|
+
export function parseAskUserQuestionTool(part: {
|
|
243
|
+
type: string
|
|
244
|
+
tool?: string
|
|
245
|
+
state?: { input?: unknown }
|
|
246
|
+
}): AskUserQuestionInput | null {
|
|
247
|
+
if (part.type !== 'tool') {
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Check for the tool name (case-insensitive)
|
|
252
|
+
const toolName = part.tool?.toLowerCase()
|
|
253
|
+
if (toolName !== 'question') {
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const input = part.state?.input as AskUserQuestionInput | undefined
|
|
258
|
+
|
|
259
|
+
if (!input?.questions || !Array.isArray(input.questions) || input.questions.length === 0) {
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Validate structure
|
|
264
|
+
for (const q of input.questions) {
|
|
265
|
+
if (
|
|
266
|
+
typeof q.question !== 'string' ||
|
|
267
|
+
typeof q.header !== 'string' ||
|
|
268
|
+
!Array.isArray(q.options) ||
|
|
269
|
+
q.options.length < 2
|
|
270
|
+
) {
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return input
|
|
276
|
+
}
|
package/src/commands/fork.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
ThreadAutoArchiveDuration,
|
|
10
10
|
type ThreadChannel,
|
|
11
11
|
} from 'discord.js'
|
|
12
|
-
import type { TextPart } from '@opencode-ai/sdk'
|
|
13
12
|
import { getDatabase } from '../database.js'
|
|
14
13
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
15
14
|
import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js'
|
|
@@ -100,7 +99,7 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
|
|
|
100
99
|
const recentMessages = userMessages.slice(-25)
|
|
101
100
|
|
|
102
101
|
const options = recentMessages.map((m, index) => {
|
|
103
|
-
const textPart = m.parts.find((p) => p.type === 'text') as
|
|
102
|
+
const textPart = m.parts.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined
|
|
104
103
|
const preview = textPart?.text?.slice(0, 80) || '(no text)'
|
|
105
104
|
const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`
|
|
106
105
|
|
package/src/commands/model.ts
CHANGED
|
@@ -13,6 +13,7 @@ import crypto from 'node:crypto'
|
|
|
13
13
|
import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js'
|
|
14
14
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
15
15
|
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
|
|
16
|
+
import { abortAndRetrySession } from '../session-handler.js'
|
|
16
17
|
import { createLogger } from '../logger.js'
|
|
17
18
|
|
|
18
19
|
const modelLogger = createLogger('MODEL')
|
|
@@ -25,6 +26,7 @@ const pendingModelContexts = new Map<string, {
|
|
|
25
26
|
isThread: boolean
|
|
26
27
|
providerId?: string
|
|
27
28
|
providerName?: string
|
|
29
|
+
thread?: ThreadChannel
|
|
28
30
|
}>()
|
|
29
31
|
|
|
30
32
|
export type ProviderInfo = {
|
|
@@ -156,6 +158,7 @@ export async function handleModelCommand({
|
|
|
156
158
|
channelId: targetChannelId,
|
|
157
159
|
sessionId: sessionId,
|
|
158
160
|
isThread: isThread,
|
|
161
|
+
thread: isThread ? (channel as ThreadChannel) : undefined,
|
|
159
162
|
}
|
|
160
163
|
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
161
164
|
pendingModelContexts.set(contextHash, context)
|
|
@@ -355,10 +358,27 @@ export async function handleModelSelectMenu(
|
|
|
355
358
|
setSessionModel(context.sessionId, fullModelId)
|
|
356
359
|
modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`)
|
|
357
360
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
361
|
+
// Check if there's a running request and abort+retry with new model
|
|
362
|
+
let retried = false
|
|
363
|
+
if (context.thread) {
|
|
364
|
+
retried = await abortAndRetrySession({
|
|
365
|
+
sessionId: context.sessionId,
|
|
366
|
+
thread: context.thread,
|
|
367
|
+
projectDirectory: context.dir,
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (retried) {
|
|
372
|
+
await interaction.editReply({
|
|
373
|
+
content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\n_Retrying current request with new model..._`,
|
|
374
|
+
components: [],
|
|
375
|
+
})
|
|
376
|
+
} else {
|
|
377
|
+
await interaction.editReply({
|
|
378
|
+
content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
|
|
379
|
+
components: [],
|
|
380
|
+
})
|
|
381
|
+
}
|
|
362
382
|
} else {
|
|
363
383
|
// Store for channel
|
|
364
384
|
setChannelModel(context.channelId, fullModelId)
|
package/src/commands/session.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { getDatabase } from '../database.js'
|
|
|
8
8
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
9
9
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
10
10
|
import { extractTagsArrays } from '../xml.js'
|
|
11
|
-
import { handleOpencodeSession
|
|
11
|
+
import { handleOpencodeSession } from '../session-handler.js'
|
|
12
12
|
import { createLogger } from '../logger.js'
|
|
13
13
|
|
|
14
14
|
const logger = createLogger('SESSION')
|
|
@@ -86,12 +86,10 @@ export async function handleSessionCommand({
|
|
|
86
86
|
|
|
87
87
|
await command.editReply(`Created new session in ${thread.toString()}`)
|
|
88
88
|
|
|
89
|
-
const parsedCommand = parseSlashCommand(fullPrompt)
|
|
90
89
|
await handleOpencodeSession({
|
|
91
90
|
prompt: fullPrompt,
|
|
92
91
|
thread,
|
|
93
92
|
projectDirectory,
|
|
94
|
-
parsedCommand,
|
|
95
93
|
channelId: textChannel.id,
|
|
96
94
|
})
|
|
97
95
|
} catch (error) {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// User-defined OpenCode command handler.
|
|
2
|
+
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
|
+
|
|
4
|
+
import type { CommandContext, CommandHandler } from './types.js'
|
|
5
|
+
import { ChannelType, type TextChannel, type ThreadChannel } from 'discord.js'
|
|
6
|
+
import { extractTagsArrays } from '../xml.js'
|
|
7
|
+
import { handleOpencodeSession } from '../session-handler.js'
|
|
8
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
9
|
+
import { createLogger } from '../logger.js'
|
|
10
|
+
import { getDatabase } from '../database.js'
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
|
|
13
|
+
const userCommandLogger = createLogger('USER_CMD')
|
|
14
|
+
|
|
15
|
+
export const handleUserCommand: CommandHandler = async ({
|
|
16
|
+
command,
|
|
17
|
+
appId,
|
|
18
|
+
}: CommandContext) => {
|
|
19
|
+
const discordCommandName = command.commandName
|
|
20
|
+
// Strip the -cmd suffix to get the actual OpenCode command name
|
|
21
|
+
const commandName = discordCommandName.replace(/-cmd$/, '')
|
|
22
|
+
const args = command.options.getString('arguments') || ''
|
|
23
|
+
|
|
24
|
+
userCommandLogger.log(
|
|
25
|
+
`Executing /${commandName} (from /${discordCommandName}) with args: ${args}`,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const channel = command.channel
|
|
29
|
+
|
|
30
|
+
userCommandLogger.log(
|
|
31
|
+
`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const isThread = channel && [
|
|
35
|
+
ChannelType.PublicThread,
|
|
36
|
+
ChannelType.PrivateThread,
|
|
37
|
+
ChannelType.AnnouncementThread,
|
|
38
|
+
].includes(channel.type)
|
|
39
|
+
|
|
40
|
+
const isTextChannel = channel?.type === ChannelType.GuildText
|
|
41
|
+
|
|
42
|
+
if (!channel || (!isTextChannel && !isThread)) {
|
|
43
|
+
await command.reply({
|
|
44
|
+
content: 'This command can only be used in text channels or threads',
|
|
45
|
+
ephemeral: true,
|
|
46
|
+
})
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let projectDirectory: string | undefined
|
|
51
|
+
let channelAppId: string | undefined
|
|
52
|
+
let textChannel: TextChannel | null = null
|
|
53
|
+
let thread: ThreadChannel | null = null
|
|
54
|
+
|
|
55
|
+
if (isThread) {
|
|
56
|
+
// Running in an existing thread - get project directory from parent channel
|
|
57
|
+
thread = channel as ThreadChannel
|
|
58
|
+
textChannel = thread.parent as TextChannel | null
|
|
59
|
+
|
|
60
|
+
// Verify this thread has an existing session
|
|
61
|
+
const row = getDatabase()
|
|
62
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
63
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
64
|
+
|
|
65
|
+
if (!row) {
|
|
66
|
+
await command.reply({
|
|
67
|
+
content: 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
|
|
68
|
+
ephemeral: true,
|
|
69
|
+
})
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (textChannel?.topic) {
|
|
74
|
+
const extracted = extractTagsArrays({
|
|
75
|
+
xml: textChannel.topic,
|
|
76
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
80
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Running in a text channel - will create a new thread
|
|
84
|
+
textChannel = channel as TextChannel
|
|
85
|
+
|
|
86
|
+
if (textChannel.topic) {
|
|
87
|
+
const extracted = extractTagsArrays({
|
|
88
|
+
xml: textChannel.topic,
|
|
89
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
93
|
+
channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (channelAppId && channelAppId !== appId) {
|
|
98
|
+
await command.reply({
|
|
99
|
+
content: 'This channel is not configured for this bot',
|
|
100
|
+
ephemeral: true,
|
|
101
|
+
})
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!projectDirectory) {
|
|
106
|
+
await command.reply({
|
|
107
|
+
content: 'This channel is not configured with a project directory',
|
|
108
|
+
ephemeral: true,
|
|
109
|
+
})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
114
|
+
await command.reply({
|
|
115
|
+
content: `Directory does not exist: ${projectDirectory}`,
|
|
116
|
+
ephemeral: true,
|
|
117
|
+
})
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await command.deferReply({ ephemeral: false })
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// Use the dedicated session.command API instead of formatting as text prompt
|
|
125
|
+
const commandPayload = { name: commandName, arguments: args }
|
|
126
|
+
|
|
127
|
+
if (isThread && thread) {
|
|
128
|
+
// Running in existing thread - just send the command
|
|
129
|
+
await command.editReply(`Running /${commandName}...`)
|
|
130
|
+
|
|
131
|
+
await handleOpencodeSession({
|
|
132
|
+
prompt: '', // Not used when command is set
|
|
133
|
+
thread,
|
|
134
|
+
projectDirectory,
|
|
135
|
+
channelId: textChannel?.id,
|
|
136
|
+
command: commandPayload,
|
|
137
|
+
})
|
|
138
|
+
} else if (textChannel) {
|
|
139
|
+
// Running in text channel - create a new thread
|
|
140
|
+
const starterMessage = await textChannel.send({
|
|
141
|
+
content: `**/${commandName}**${args ? ` ${args.slice(0, 200)}${args.length > 200 ? '…' : ''}` : ''}`,
|
|
142
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const threadName = `/${commandName} ${args.slice(0, 80)}${args.length > 80 ? '…' : ''}`
|
|
146
|
+
const newThread = await starterMessage.startThread({
|
|
147
|
+
name: threadName.slice(0, 100),
|
|
148
|
+
autoArchiveDuration: 1440,
|
|
149
|
+
reason: `OpenCode command: ${commandName}`,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
await command.editReply(`Started /${commandName} in ${newThread.toString()}`)
|
|
153
|
+
|
|
154
|
+
await handleOpencodeSession({
|
|
155
|
+
prompt: '', // Not used when command is set
|
|
156
|
+
thread: newThread,
|
|
157
|
+
projectDirectory,
|
|
158
|
+
channelId: textChannel.id,
|
|
159
|
+
command: commandPayload,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
userCommandLogger.error(`Error executing /${commandName}:`, error)
|
|
164
|
+
|
|
165
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
166
|
+
|
|
167
|
+
if (command.deferred) {
|
|
168
|
+
await command.editReply({
|
|
169
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
170
|
+
})
|
|
171
|
+
} else {
|
|
172
|
+
await command.reply({
|
|
173
|
+
content: `Failed to execute /${commandName}: ${errorMessage}`,
|
|
174
|
+
ephemeral: true,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/database.ts
CHANGED
|
@@ -100,6 +100,23 @@ export function runModelMigrations(database?: Database.Database): void {
|
|
|
100
100
|
)
|
|
101
101
|
`)
|
|
102
102
|
|
|
103
|
+
targetDb.exec(`
|
|
104
|
+
CREATE TABLE IF NOT EXISTS channel_agents (
|
|
105
|
+
channel_id TEXT PRIMARY KEY,
|
|
106
|
+
agent_name TEXT NOT NULL,
|
|
107
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
108
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
109
|
+
)
|
|
110
|
+
`)
|
|
111
|
+
|
|
112
|
+
targetDb.exec(`
|
|
113
|
+
CREATE TABLE IF NOT EXISTS session_agents (
|
|
114
|
+
session_id TEXT PRIMARY KEY,
|
|
115
|
+
agent_name TEXT NOT NULL,
|
|
116
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
117
|
+
)
|
|
118
|
+
`)
|
|
119
|
+
|
|
103
120
|
dbLogger.log('Model preferences migrations complete')
|
|
104
121
|
}
|
|
105
122
|
|
|
@@ -151,6 +168,50 @@ export function setSessionModel(sessionId: string, modelId: string): void {
|
|
|
151
168
|
).run(sessionId, modelId)
|
|
152
169
|
}
|
|
153
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Get the agent preference for a channel.
|
|
173
|
+
*/
|
|
174
|
+
export function getChannelAgent(channelId: string): string | undefined {
|
|
175
|
+
const db = getDatabase()
|
|
176
|
+
const row = db
|
|
177
|
+
.prepare('SELECT agent_name FROM channel_agents WHERE channel_id = ?')
|
|
178
|
+
.get(channelId) as { agent_name: string } | undefined
|
|
179
|
+
return row?.agent_name
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Set the agent preference for a channel.
|
|
184
|
+
*/
|
|
185
|
+
export function setChannelAgent(channelId: string, agentName: string): void {
|
|
186
|
+
const db = getDatabase()
|
|
187
|
+
db.prepare(
|
|
188
|
+
`INSERT INTO channel_agents (channel_id, agent_name, updated_at)
|
|
189
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
190
|
+
ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`
|
|
191
|
+
).run(channelId, agentName, agentName)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the agent preference for a session.
|
|
196
|
+
*/
|
|
197
|
+
export function getSessionAgent(sessionId: string): string | undefined {
|
|
198
|
+
const db = getDatabase()
|
|
199
|
+
const row = db
|
|
200
|
+
.prepare('SELECT agent_name FROM session_agents WHERE session_id = ?')
|
|
201
|
+
.get(sessionId) as { agent_name: string } | undefined
|
|
202
|
+
return row?.agent_name
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Set the agent preference for a session.
|
|
207
|
+
*/
|
|
208
|
+
export function setSessionAgent(sessionId: string, agentName: string): void {
|
|
209
|
+
const db = getDatabase()
|
|
210
|
+
db.prepare(
|
|
211
|
+
`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`
|
|
212
|
+
).run(sessionId, agentName)
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
export function closeDatabase(): void {
|
|
155
216
|
if (db) {
|
|
156
217
|
db.close()
|