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
@@ -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
+ }
@@ -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 TextPart | undefined
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
 
@@ -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
- await interaction.editReply({
359
- content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
360
- components: [],
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)
@@ -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, parseSlashCommand } from '../session-handler.js'
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()