shuvmaki 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 (94) hide show
  1. package/bin.js +70 -0
  2. package/dist/ai-tool-to-genai.js +210 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/channel-management.js +97 -0
  5. package/dist/cli.js +709 -0
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +184 -0
  22. package/dist/discord-bot.js +384 -0
  23. package/dist/discord-utils.js +217 -0
  24. package/dist/escape-backticks.test.js +410 -0
  25. package/dist/format-tables.js +96 -0
  26. package/dist/format-tables.test.js +418 -0
  27. package/dist/genai-worker-wrapper.js +109 -0
  28. package/dist/genai-worker.js +297 -0
  29. package/dist/genai.js +232 -0
  30. package/dist/interaction-handler.js +144 -0
  31. package/dist/logger.js +51 -0
  32. package/dist/markdown.js +310 -0
  33. package/dist/markdown.test.js +262 -0
  34. package/dist/message-formatting.js +273 -0
  35. package/dist/message-formatting.test.js +73 -0
  36. package/dist/openai-realtime.js +228 -0
  37. package/dist/opencode.js +216 -0
  38. package/dist/session-handler.js +580 -0
  39. package/dist/system-message.js +61 -0
  40. package/dist/tools.js +356 -0
  41. package/dist/utils.js +85 -0
  42. package/dist/voice-handler.js +541 -0
  43. package/dist/voice.js +314 -0
  44. package/dist/worker-types.js +4 -0
  45. package/dist/xml.js +92 -0
  46. package/dist/xml.test.js +32 -0
  47. package/package.json +60 -0
  48. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  49. package/src/__snapshots__/compact-session-context.md +47 -0
  50. package/src/ai-tool-to-genai.test.ts +296 -0
  51. package/src/ai-tool-to-genai.ts +255 -0
  52. package/src/channel-management.ts +161 -0
  53. package/src/cli.ts +1010 -0
  54. package/src/commands/abort.ts +94 -0
  55. package/src/commands/add-project.ts +139 -0
  56. package/src/commands/agent.ts +201 -0
  57. package/src/commands/ask-question.ts +276 -0
  58. package/src/commands/create-new-project.ts +111 -0
  59. package/src/commands/fork.ts +257 -0
  60. package/src/commands/model.ts +402 -0
  61. package/src/commands/permissions.ts +146 -0
  62. package/src/commands/queue.ts +181 -0
  63. package/src/commands/resume.ts +230 -0
  64. package/src/commands/session.ts +184 -0
  65. package/src/commands/share.ts +96 -0
  66. package/src/commands/types.ts +25 -0
  67. package/src/commands/undo-redo.ts +213 -0
  68. package/src/commands/user-command.ts +178 -0
  69. package/src/database.ts +220 -0
  70. package/src/discord-bot.ts +513 -0
  71. package/src/discord-utils.ts +282 -0
  72. package/src/escape-backticks.test.ts +447 -0
  73. package/src/format-tables.test.ts +440 -0
  74. package/src/format-tables.ts +110 -0
  75. package/src/genai-worker-wrapper.ts +160 -0
  76. package/src/genai-worker.ts +366 -0
  77. package/src/genai.ts +321 -0
  78. package/src/interaction-handler.ts +187 -0
  79. package/src/logger.ts +57 -0
  80. package/src/markdown.test.ts +358 -0
  81. package/src/markdown.ts +365 -0
  82. package/src/message-formatting.test.ts +81 -0
  83. package/src/message-formatting.ts +340 -0
  84. package/src/openai-realtime.ts +363 -0
  85. package/src/opencode.ts +277 -0
  86. package/src/session-handler.ts +758 -0
  87. package/src/system-message.ts +62 -0
  88. package/src/tools.ts +428 -0
  89. package/src/utils.ts +118 -0
  90. package/src/voice-handler.ts +760 -0
  91. package/src/voice.ts +432 -0
  92. package/src/worker-types.ts +66 -0
  93. package/src/xml.test.ts +37 -0
  94. package/src/xml.ts +121 -0
@@ -0,0 +1,213 @@
1
+ // Undo/Redo commands - /undo, /redo
2
+
3
+ import { ChannelType, type ThreadChannel } from 'discord.js'
4
+ import type { CommandContext } from './types.js'
5
+ import { getDatabase } from '../database.js'
6
+ import { initializeOpencodeForDirectory } from '../opencode.js'
7
+ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
+ import { createLogger } from '../logger.js'
9
+
10
+ const logger = createLogger('UNDO-REDO')
11
+
12
+ export async function handleUndoCommand({
13
+ command,
14
+ }: CommandContext): Promise<void> {
15
+ const channel = command.channel
16
+
17
+ if (!channel) {
18
+ await command.reply({
19
+ content: 'This command can only be used in a channel',
20
+ ephemeral: true,
21
+ flags: SILENT_MESSAGE_FLAGS,
22
+ })
23
+ return
24
+ }
25
+
26
+ const isThread = [
27
+ ChannelType.PublicThread,
28
+ ChannelType.PrivateThread,
29
+ ChannelType.AnnouncementThread,
30
+ ].includes(channel.type)
31
+
32
+ if (!isThread) {
33
+ await command.reply({
34
+ content: 'This command can only be used in a thread with an active session',
35
+ ephemeral: true,
36
+ flags: SILENT_MESSAGE_FLAGS,
37
+ })
38
+ return
39
+ }
40
+
41
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
42
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
43
+
44
+ if (!directory) {
45
+ await command.reply({
46
+ content: 'Could not determine project directory for this channel',
47
+ ephemeral: true,
48
+ flags: SILENT_MESSAGE_FLAGS,
49
+ })
50
+ return
51
+ }
52
+
53
+ const row = getDatabase()
54
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
55
+ .get(channel.id) as { session_id: string } | undefined
56
+
57
+ if (!row?.session_id) {
58
+ await command.reply({
59
+ content: 'No active session in this thread',
60
+ ephemeral: true,
61
+ flags: SILENT_MESSAGE_FLAGS,
62
+ })
63
+ return
64
+ }
65
+
66
+ const sessionId = row.session_id
67
+
68
+ try {
69
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
70
+
71
+ const getClient = await initializeOpencodeForDirectory(directory)
72
+
73
+ // Fetch messages to find the last assistant message
74
+ const messagesResponse = await getClient().session.messages({
75
+ path: { id: sessionId },
76
+ })
77
+
78
+ if (!messagesResponse.data || messagesResponse.data.length === 0) {
79
+ await command.editReply('No messages to undo')
80
+ return
81
+ }
82
+
83
+ // Find the last assistant message
84
+ const lastAssistantMessage = [...messagesResponse.data]
85
+ .reverse()
86
+ .find((m) => m.info.role === 'assistant')
87
+
88
+ if (!lastAssistantMessage) {
89
+ await command.editReply('No assistant message to undo')
90
+ return
91
+ }
92
+
93
+ const response = await getClient().session.revert({
94
+ path: { id: sessionId },
95
+ body: { messageID: lastAssistantMessage.info.id },
96
+ })
97
+
98
+ if (response.error) {
99
+ await command.editReply(
100
+ `Failed to undo: ${JSON.stringify(response.error)}`,
101
+ )
102
+ return
103
+ }
104
+
105
+ const diffInfo = response.data?.revert?.diff
106
+ ? `\n\`\`\`diff\n${response.data.revert.diff.slice(0, 1500)}\n\`\`\``
107
+ : ''
108
+
109
+ await command.editReply(
110
+ `⏪ **Undone** - reverted last assistant message${diffInfo}`,
111
+ )
112
+ logger.log(
113
+ `Session ${sessionId} reverted message ${lastAssistantMessage.info.id}`,
114
+ )
115
+ } catch (error) {
116
+ logger.error('[UNDO] Error:', error)
117
+ await command.editReply(
118
+ `Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}`,
119
+ )
120
+ }
121
+ }
122
+
123
+ export async function handleRedoCommand({
124
+ command,
125
+ }: CommandContext): Promise<void> {
126
+ const channel = command.channel
127
+
128
+ if (!channel) {
129
+ await command.reply({
130
+ content: 'This command can only be used in a channel',
131
+ ephemeral: true,
132
+ flags: SILENT_MESSAGE_FLAGS,
133
+ })
134
+ return
135
+ }
136
+
137
+ const isThread = [
138
+ ChannelType.PublicThread,
139
+ ChannelType.PrivateThread,
140
+ ChannelType.AnnouncementThread,
141
+ ].includes(channel.type)
142
+
143
+ if (!isThread) {
144
+ await command.reply({
145
+ content: 'This command can only be used in a thread with an active session',
146
+ ephemeral: true,
147
+ flags: SILENT_MESSAGE_FLAGS,
148
+ })
149
+ return
150
+ }
151
+
152
+ const textChannel = await resolveTextChannel(channel as ThreadChannel)
153
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel)
154
+
155
+ if (!directory) {
156
+ await command.reply({
157
+ content: 'Could not determine project directory for this channel',
158
+ ephemeral: true,
159
+ flags: SILENT_MESSAGE_FLAGS,
160
+ })
161
+ return
162
+ }
163
+
164
+ const row = getDatabase()
165
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
166
+ .get(channel.id) as { session_id: string } | undefined
167
+
168
+ if (!row?.session_id) {
169
+ await command.reply({
170
+ content: 'No active session in this thread',
171
+ ephemeral: true,
172
+ flags: SILENT_MESSAGE_FLAGS,
173
+ })
174
+ return
175
+ }
176
+
177
+ const sessionId = row.session_id
178
+
179
+ try {
180
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
181
+
182
+ const getClient = await initializeOpencodeForDirectory(directory)
183
+
184
+ // Check if session has reverted state
185
+ const sessionResponse = await getClient().session.get({
186
+ path: { id: sessionId },
187
+ })
188
+
189
+ if (!sessionResponse.data?.revert) {
190
+ await command.editReply('Nothing to redo - no previous undo found')
191
+ return
192
+ }
193
+
194
+ const response = await getClient().session.unrevert({
195
+ path: { id: sessionId },
196
+ })
197
+
198
+ if (response.error) {
199
+ await command.editReply(
200
+ `Failed to redo: ${JSON.stringify(response.error)}`,
201
+ )
202
+ return
203
+ }
204
+
205
+ await command.editReply(`⏩ **Restored** - session back to previous state`)
206
+ logger.log(`Session ${sessionId} unrevert completed`)
207
+ } catch (error) {
208
+ logger.error('[REDO] Error:', error)
209
+ await command.editReply(
210
+ `Failed to redo: ${error instanceof Error ? error.message : 'Unknown error'}`,
211
+ )
212
+ }
213
+ }
@@ -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
+ }
@@ -0,0 +1,220 @@
1
+ // SQLite database manager for persistent bot state.
2
+ // Stores thread-session mappings, bot tokens, channel directories,
3
+ // API keys, and model preferences in ~/.kimaki/discord-sessions.db.
4
+
5
+ import Database from 'better-sqlite3'
6
+ import fs from 'node:fs'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import { createLogger } from './logger.js'
10
+
11
+ const dbLogger = createLogger('DB')
12
+
13
+ let db: Database.Database | null = null
14
+
15
+ export function getDatabase(): Database.Database {
16
+ if (!db) {
17
+ const kimakiDir = path.join(os.homedir(), '.kimaki')
18
+
19
+ try {
20
+ fs.mkdirSync(kimakiDir, { recursive: true })
21
+ } catch (error) {
22
+ dbLogger.error('Failed to create ~/.kimaki directory:', error)
23
+ }
24
+
25
+ const dbPath = path.join(kimakiDir, 'discord-sessions.db')
26
+
27
+ dbLogger.log(`Opening database at: ${dbPath}`)
28
+ db = new Database(dbPath)
29
+
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS thread_sessions (
32
+ thread_id TEXT PRIMARY KEY,
33
+ session_id TEXT NOT NULL,
34
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
35
+ )
36
+ `)
37
+
38
+ db.exec(`
39
+ CREATE TABLE IF NOT EXISTS part_messages (
40
+ part_id TEXT PRIMARY KEY,
41
+ message_id TEXT NOT NULL,
42
+ thread_id TEXT NOT NULL,
43
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
44
+ )
45
+ `)
46
+
47
+ db.exec(`
48
+ CREATE TABLE IF NOT EXISTS bot_tokens (
49
+ app_id TEXT PRIMARY KEY,
50
+ token TEXT NOT NULL,
51
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
52
+ )
53
+ `)
54
+
55
+ db.exec(`
56
+ CREATE TABLE IF NOT EXISTS channel_directories (
57
+ channel_id TEXT PRIMARY KEY,
58
+ directory TEXT NOT NULL,
59
+ channel_type TEXT NOT NULL,
60
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
61
+ )
62
+ `)
63
+
64
+ db.exec(`
65
+ CREATE TABLE IF NOT EXISTS bot_api_keys (
66
+ app_id TEXT PRIMARY KEY,
67
+ gemini_api_key TEXT,
68
+ xai_api_key TEXT,
69
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
70
+ )
71
+ `)
72
+
73
+ runModelMigrations(db)
74
+ }
75
+
76
+ return db
77
+ }
78
+
79
+ /**
80
+ * Run migrations for model preferences tables.
81
+ * Called on startup and can be called on-demand.
82
+ */
83
+ export function runModelMigrations(database?: Database.Database): void {
84
+ const targetDb = database || getDatabase()
85
+
86
+ targetDb.exec(`
87
+ CREATE TABLE IF NOT EXISTS channel_models (
88
+ channel_id TEXT PRIMARY KEY,
89
+ model_id TEXT NOT NULL,
90
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
91
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
92
+ )
93
+ `)
94
+
95
+ targetDb.exec(`
96
+ CREATE TABLE IF NOT EXISTS session_models (
97
+ session_id TEXT PRIMARY KEY,
98
+ model_id TEXT NOT NULL,
99
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
100
+ )
101
+ `)
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
+
120
+ dbLogger.log('Model preferences migrations complete')
121
+ }
122
+
123
+ /**
124
+ * Get the model preference for a channel.
125
+ * @returns Model ID in format "provider_id/model_id" or undefined
126
+ */
127
+ export function getChannelModel(channelId: string): string | undefined {
128
+ const db = getDatabase()
129
+ const row = db
130
+ .prepare('SELECT model_id FROM channel_models WHERE channel_id = ?')
131
+ .get(channelId) as { model_id: string } | undefined
132
+ return row?.model_id
133
+ }
134
+
135
+ /**
136
+ * Set the model preference for a channel.
137
+ * @param modelId Model ID in format "provider_id/model_id"
138
+ */
139
+ export function setChannelModel(channelId: string, modelId: string): void {
140
+ const db = getDatabase()
141
+ db.prepare(
142
+ `INSERT INTO channel_models (channel_id, model_id, updated_at)
143
+ VALUES (?, ?, CURRENT_TIMESTAMP)
144
+ ON CONFLICT(channel_id) DO UPDATE SET model_id = ?, updated_at = CURRENT_TIMESTAMP`
145
+ ).run(channelId, modelId, modelId)
146
+ }
147
+
148
+ /**
149
+ * Get the model preference for a session.
150
+ * @returns Model ID in format "provider_id/model_id" or undefined
151
+ */
152
+ export function getSessionModel(sessionId: string): string | undefined {
153
+ const db = getDatabase()
154
+ const row = db
155
+ .prepare('SELECT model_id FROM session_models WHERE session_id = ?')
156
+ .get(sessionId) as { model_id: string } | undefined
157
+ return row?.model_id
158
+ }
159
+
160
+ /**
161
+ * Set the model preference for a session.
162
+ * @param modelId Model ID in format "provider_id/model_id"
163
+ */
164
+ export function setSessionModel(sessionId: string, modelId: string): void {
165
+ const db = getDatabase()
166
+ db.prepare(
167
+ `INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`
168
+ ).run(sessionId, modelId)
169
+ }
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
+
215
+ export function closeDatabase(): void {
216
+ if (db) {
217
+ db.close()
218
+ db = null
219
+ }
220
+ }