kimaki 0.4.37 → 0.4.39

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 (53) hide show
  1. package/dist/channel-management.js +6 -2
  2. package/dist/cli.js +41 -15
  3. package/dist/commands/abort.js +15 -6
  4. package/dist/commands/add-project.js +9 -0
  5. package/dist/commands/agent.js +114 -20
  6. package/dist/commands/fork.js +13 -2
  7. package/dist/commands/model.js +12 -0
  8. package/dist/commands/remove-project.js +26 -16
  9. package/dist/commands/resume.js +9 -0
  10. package/dist/commands/session.js +13 -0
  11. package/dist/commands/share.js +10 -1
  12. package/dist/commands/undo-redo.js +13 -4
  13. package/dist/database.js +24 -5
  14. package/dist/discord-bot.js +38 -31
  15. package/dist/errors.js +110 -0
  16. package/dist/genai-worker.js +18 -16
  17. package/dist/interaction-handler.js +6 -1
  18. package/dist/markdown.js +96 -85
  19. package/dist/markdown.test.js +10 -3
  20. package/dist/message-formatting.js +50 -37
  21. package/dist/opencode.js +43 -46
  22. package/dist/session-handler.js +136 -8
  23. package/dist/system-message.js +2 -0
  24. package/dist/tools.js +18 -8
  25. package/dist/voice-handler.js +48 -25
  26. package/dist/voice.js +159 -131
  27. package/package.json +2 -1
  28. package/src/channel-management.ts +6 -2
  29. package/src/cli.ts +67 -19
  30. package/src/commands/abort.ts +17 -7
  31. package/src/commands/add-project.ts +9 -0
  32. package/src/commands/agent.ts +160 -25
  33. package/src/commands/fork.ts +18 -7
  34. package/src/commands/model.ts +12 -0
  35. package/src/commands/remove-project.ts +28 -16
  36. package/src/commands/resume.ts +9 -0
  37. package/src/commands/session.ts +13 -0
  38. package/src/commands/share.ts +11 -1
  39. package/src/commands/undo-redo.ts +15 -6
  40. package/src/database.ts +26 -4
  41. package/src/discord-bot.ts +42 -34
  42. package/src/errors.ts +208 -0
  43. package/src/genai-worker.ts +20 -17
  44. package/src/interaction-handler.ts +7 -1
  45. package/src/markdown.test.ts +13 -3
  46. package/src/markdown.ts +111 -95
  47. package/src/message-formatting.ts +55 -38
  48. package/src/opencode.ts +52 -49
  49. package/src/session-handler.ts +164 -11
  50. package/src/system-message.ts +2 -0
  51. package/src/tools.ts +18 -8
  52. package/src/voice-handler.ts +48 -23
  53. package/src/voice.ts +195 -148
@@ -1,4 +1,5 @@
1
1
  // /agent command - Set the preferred agent for this channel or session.
2
+ // Also provides quick agent commands like /plan-agent, /build-agent that switch instantly.
2
3
 
3
4
  import {
4
5
  ChatInputCommandInteraction,
@@ -10,10 +11,11 @@ import {
10
11
  type TextChannel,
11
12
  } from 'discord.js'
12
13
  import crypto from 'node:crypto'
13
- import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js'
14
+ import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runModelMigrations } from '../database.js'
14
15
  import { initializeOpencodeForDirectory } from '../opencode.js'
15
16
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
17
  import { createLogger } from '../logger.js'
18
+ import * as errore from 'errore'
17
19
 
18
20
  const agentLogger = createLogger('AGENT')
19
21
 
@@ -27,22 +29,40 @@ const pendingAgentContexts = new Map<
27
29
  }
28
30
  >()
29
31
 
30
- export async function handleAgentCommand({
32
+ /**
33
+ * Context for agent commands, containing channel/session info.
34
+ */
35
+ export type AgentCommandContext = {
36
+ dir: string
37
+ channelId: string
38
+ sessionId?: string
39
+ isThread: boolean
40
+ }
41
+
42
+ /**
43
+ * Sanitize an agent name to be a valid Discord command name component.
44
+ * Lowercase, alphanumeric and hyphens only.
45
+ */
46
+ export function sanitizeAgentName(name: string): string {
47
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
48
+ }
49
+
50
+ /**
51
+ * Resolve the context for an agent command (directory, channel, session).
52
+ * Returns null if the command cannot be executed in this context.
53
+ */
54
+ export async function resolveAgentCommandContext({
31
55
  interaction,
32
56
  appId,
33
57
  }: {
34
58
  interaction: ChatInputCommandInteraction
35
59
  appId: string
36
- }): Promise<void> {
37
- await interaction.deferReply({ ephemeral: true })
38
-
39
- runModelMigrations()
40
-
60
+ }): Promise<AgentCommandContext | null> {
41
61
  const channel = interaction.channel
42
62
 
43
63
  if (!channel) {
44
64
  await interaction.editReply({ content: 'This command can only be used in a channel' })
45
- return
65
+ return null
46
66
  }
47
67
 
48
68
  const isThread = [
@@ -78,26 +98,77 @@ export async function handleAgentCommand({
78
98
  await interaction.editReply({
79
99
  content: 'This command can only be used in text channels or threads',
80
100
  })
81
- return
101
+ return null
82
102
  }
83
103
 
84
104
  if (channelAppId && channelAppId !== appId) {
85
105
  await interaction.editReply({ content: 'This channel is not configured for this bot' })
86
- return
106
+ return null
87
107
  }
88
108
 
89
109
  if (!projectDirectory) {
90
110
  await interaction.editReply({
91
111
  content: 'This channel is not configured with a project directory',
92
112
  })
113
+ return null
114
+ }
115
+
116
+ return {
117
+ dir: projectDirectory,
118
+ channelId: targetChannelId,
119
+ sessionId,
120
+ isThread,
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Set the agent preference for a context (session or channel).
126
+ * When switching agents for a session, also clears the session model preference
127
+ * so the new agent's model takes effect.
128
+ */
129
+ export function setAgentForContext({
130
+ context,
131
+ agentName,
132
+ }: {
133
+ context: AgentCommandContext
134
+ agentName: string
135
+ }): void {
136
+ if (context.isThread && context.sessionId) {
137
+ setSessionAgent(context.sessionId, agentName)
138
+ // Clear session model so the new agent's model takes effect
139
+ clearSessionModel(context.sessionId)
140
+ agentLogger.log(`Set agent ${agentName} for session ${context.sessionId} (cleared model preference)`)
141
+ } else {
142
+ setChannelAgent(context.channelId, agentName)
143
+ agentLogger.log(`Set agent ${agentName} for channel ${context.channelId}`)
144
+ }
145
+ }
146
+
147
+ export async function handleAgentCommand({
148
+ interaction,
149
+ appId,
150
+ }: {
151
+ interaction: ChatInputCommandInteraction
152
+ appId: string
153
+ }): Promise<void> {
154
+ await interaction.deferReply({ ephemeral: true })
155
+
156
+ runModelMigrations()
157
+
158
+ const context = await resolveAgentCommandContext({ interaction, appId })
159
+ if (!context) {
93
160
  return
94
161
  }
95
162
 
96
163
  try {
97
- const getClient = await initializeOpencodeForDirectory(projectDirectory)
164
+ const getClient = await initializeOpencodeForDirectory(context.dir)
165
+ if (errore.isError(getClient)) {
166
+ await interaction.editReply({ content: getClient.message })
167
+ return
168
+ }
98
169
 
99
170
  const agentsResponse = await getClient().app.agents({
100
- query: { directory: projectDirectory },
171
+ query: { directory: context.dir },
101
172
  })
102
173
 
103
174
  if (!agentsResponse.data || agentsResponse.data.length === 0) {
@@ -106,7 +177,10 @@ export async function handleAgentCommand({
106
177
  }
107
178
 
108
179
  const agents = agentsResponse.data
109
- .filter((a) => a.mode === 'primary' || a.mode === 'all')
180
+ .filter((agent) => {
181
+ const hidden = (agent as { hidden?: boolean }).hidden
182
+ return (agent.mode === 'primary' || agent.mode === 'all') && !hidden
183
+ })
110
184
  .slice(0, 25)
111
185
 
112
186
  if (agents.length === 0) {
@@ -115,12 +189,7 @@ export async function handleAgentCommand({
115
189
  }
116
190
 
117
191
  const contextHash = crypto.randomBytes(8).toString('hex')
118
- pendingAgentContexts.set(contextHash, {
119
- dir: projectDirectory,
120
- channelId: targetChannelId,
121
- sessionId,
122
- isThread,
123
- })
192
+ pendingAgentContexts.set(contextHash, context)
124
193
 
125
194
  const options = agents.map((agent) => ({
126
195
  label: agent.name.slice(0, 100),
@@ -179,18 +248,14 @@ export async function handleAgentSelectMenu(
179
248
  }
180
249
 
181
250
  try {
182
- if (context.isThread && context.sessionId) {
183
- setSessionAgent(context.sessionId, selectedAgent)
184
- agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`)
251
+ setAgentForContext({ context, agentName: selectedAgent })
185
252
 
253
+ if (context.isThread && context.sessionId) {
186
254
  await interaction.editReply({
187
255
  content: `Agent preference set for this session: **${selectedAgent}**`,
188
256
  components: [],
189
257
  })
190
258
  } else {
191
- setChannelAgent(context.channelId, selectedAgent)
192
- agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`)
193
-
194
259
  await interaction.editReply({
195
260
  content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
196
261
  components: [],
@@ -206,3 +271,73 @@ export async function handleAgentSelectMenu(
206
271
  })
207
272
  }
208
273
  }
274
+
275
+ /**
276
+ * Handle quick agent commands like /plan-agent, /build-agent.
277
+ * These instantly switch to the specified agent without showing a dropdown.
278
+ */
279
+ export async function handleQuickAgentCommand({
280
+ command,
281
+ appId,
282
+ }: {
283
+ command: ChatInputCommandInteraction
284
+ appId: string
285
+ }): Promise<void> {
286
+ await command.deferReply({ ephemeral: true })
287
+
288
+ runModelMigrations()
289
+
290
+ // Extract agent name from command: "plan-agent" → "plan"
291
+ const sanitizedAgentName = command.commandName.replace(/-agent$/, '')
292
+
293
+ const context = await resolveAgentCommandContext({ interaction: command, appId })
294
+ if (!context) {
295
+ return
296
+ }
297
+
298
+ try {
299
+ const getClient = await initializeOpencodeForDirectory(context.dir)
300
+ if (errore.isError(getClient)) {
301
+ await command.editReply({ content: getClient.message })
302
+ return
303
+ }
304
+
305
+ const agentsResponse = await getClient().app.agents({
306
+ query: { directory: context.dir },
307
+ })
308
+
309
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
310
+ await command.editReply({ content: 'No agents available in this project' })
311
+ return
312
+ }
313
+
314
+ // Find the agent matching the sanitized command name
315
+ const matchingAgent = agentsResponse.data.find(
316
+ (a) => sanitizeAgentName(a.name) === sanitizedAgentName
317
+ )
318
+
319
+ if (!matchingAgent) {
320
+ await command.editReply({
321
+ content: `Agent not found. Available agents: ${agentsResponse.data.map((a) => a.name).join(', ')}`,
322
+ })
323
+ return
324
+ }
325
+
326
+ setAgentForContext({ context, agentName: matchingAgent.name })
327
+
328
+ if (context.isThread && context.sessionId) {
329
+ await command.editReply({
330
+ content: `Switched to **${matchingAgent.name}** agent for this session`,
331
+ })
332
+ } else {
333
+ await command.editReply({
334
+ content: `Switched to **${matchingAgent.name}** agent for this channel\n\nAll new sessions will use this agent.`,
335
+ })
336
+ }
337
+ } catch (error) {
338
+ agentLogger.error('Error in quick agent command:', error)
339
+ await command.editReply({
340
+ content: `Failed to switch agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
341
+ })
342
+ }
343
+ }
@@ -14,6 +14,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
14
14
  import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js'
15
15
  import { collectLastAssistantParts } from '../message-formatting.js'
16
16
  import { createLogger } from '../logger.js'
17
+ import * as errore from 'errore'
17
18
 
18
19
  const sessionLogger = createLogger('SESSION')
19
20
  const forkLogger = createLogger('FORK')
@@ -71,9 +72,15 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
71
72
 
72
73
  const sessionId = row.session_id
73
74
 
74
- try {
75
- const getClient = await initializeOpencodeForDirectory(directory)
75
+ const getClient = await initializeOpencodeForDirectory(directory)
76
+ if (errore.isError(getClient)) {
77
+ await interaction.editReply({
78
+ content: `Failed to load messages: ${getClient.message}`,
79
+ })
80
+ return
81
+ }
76
82
 
83
+ try {
77
84
  const messagesResponse = await getClient().session.messages({
78
85
  path: { id: sessionId },
79
86
  })
@@ -85,7 +92,7 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
85
92
  return
86
93
  }
87
94
 
88
- const userMessages = messagesResponse.data.filter((m) => m.info.role === 'user')
95
+ const userMessages = messagesResponse.data.filter((m: { info: { role: string } }) => m.info.role === 'user')
89
96
 
90
97
  if (userMessages.length === 0) {
91
98
  await interaction.editReply({
@@ -96,8 +103,8 @@ export async function handleForkCommand(interaction: ChatInputCommandInteraction
96
103
 
97
104
  const recentMessages = userMessages.slice(-25)
98
105
 
99
- const options = recentMessages.map((m, index) => {
100
- const textPart = m.parts.find((p) => p.type === 'text') as
106
+ const options = recentMessages.map((m: { parts: Array<{ type: string; text?: string }>; info: { id: string; time: { created: number } } }, index: number) => {
107
+ const textPart = m.parts.find((p: { type: string }) => p.type === 'text') as
101
108
  | { type: 'text'; text: string }
102
109
  | undefined
103
110
  const preview = textPart?.text?.slice(0, 80) || '(no text)'
@@ -163,9 +170,13 @@ export async function handleForkSelectMenu(
163
170
 
164
171
  await interaction.deferReply({ ephemeral: false })
165
172
 
166
- try {
167
- const getClient = await initializeOpencodeForDirectory(directory)
173
+ const getClient = await initializeOpencodeForDirectory(directory)
174
+ if (errore.isError(getClient)) {
175
+ await interaction.editReply(`Failed to fork session: ${getClient.message}`)
176
+ return
177
+ }
168
178
 
179
+ try {
169
180
  const forkResponse = await getClient().session.fork({
170
181
  path: { id: sessionId },
171
182
  body: { messageID: selectedMessageId },
@@ -15,6 +15,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js'
15
15
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
16
  import { abortAndRetrySession } from '../session-handler.js'
17
17
  import { createLogger } from '../logger.js'
18
+ import * as errore from 'errore'
18
19
 
19
20
  const modelLogger = createLogger('MODEL')
20
21
 
@@ -128,6 +129,10 @@ export async function handleModelCommand({
128
129
 
129
130
  try {
130
131
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
132
+ if (errore.isError(getClient)) {
133
+ await interaction.editReply({ content: getClient.message })
134
+ return
135
+ }
131
136
 
132
137
  const providersResponse = await getClient().provider.list({
133
138
  query: { directory: projectDirectory },
@@ -232,6 +237,13 @@ export async function handleProviderSelectMenu(
232
237
 
233
238
  try {
234
239
  const getClient = await initializeOpencodeForDirectory(context.dir)
240
+ if (errore.isError(getClient)) {
241
+ await interaction.editReply({
242
+ content: getClient.message,
243
+ components: [],
244
+ })
245
+ return
246
+ }
235
247
 
236
248
  const providersResponse = await getClient().provider.list({
237
249
  query: { directory: context.dir },
@@ -1,6 +1,7 @@
1
1
  // /remove-project command - Remove Discord channels for a project.
2
2
 
3
3
  import path from 'node:path'
4
+ import * as errore from 'errore'
4
5
  import type { CommandContext, AutocompleteContext } from './types.js'
5
6
  import { getDatabase } from '../database.js'
6
7
  import { createLogger } from '../logger.js'
@@ -36,19 +37,27 @@ export async function handleRemoveProjectCommand({ command, appId }: CommandCont
36
37
  const failedChannels: string[] = []
37
38
 
38
39
  for (const { channel_id, channel_type } of channels) {
39
- try {
40
- const channel = await guild.channels.fetch(channel_id).catch(() => null)
41
-
42
- if (channel) {
40
+ const channel = await errore.tryAsync({
41
+ try: () => guild.channels.fetch(channel_id),
42
+ catch: (e) => e as Error,
43
+ })
44
+
45
+ if (errore.isError(channel)) {
46
+ logger.error(`Failed to fetch channel ${channel_id}:`, channel)
47
+ failedChannels.push(`${channel_type}: ${channel_id}`)
48
+ continue
49
+ }
50
+
51
+ if (channel) {
52
+ try {
43
53
  await channel.delete(`Removed by /remove-project command`)
44
54
  deletedChannels.push(`${channel_type}: ${channel_id}`)
45
- } else {
46
- // Channel doesn't exist in this guild or was already deleted
47
- deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`)
55
+ } catch (error) {
56
+ logger.error(`Failed to delete channel ${channel_id}:`, error)
57
+ failedChannels.push(`${channel_type}: ${channel_id}`)
48
58
  }
49
- } catch (error) {
50
- logger.error(`Failed to delete channel ${channel_id}:`, error)
51
- failedChannels.push(`${channel_type}: ${channel_id}`)
59
+ } else {
60
+ deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`)
52
61
  }
53
62
  }
54
63
 
@@ -103,13 +112,16 @@ export async function handleRemoveProjectAutocomplete({
103
112
  const projectsInGuild: { directory: string; channelId: string }[] = []
104
113
 
105
114
  for (const { directory, channel_id } of allChannels) {
106
- try {
107
- const channel = await guild.channels.fetch(channel_id).catch(() => null)
108
- if (channel) {
109
- projectsInGuild.push({ directory, channelId: channel_id })
110
- }
111
- } catch {
115
+ const channel = await errore.tryAsync({
116
+ try: () => guild.channels.fetch(channel_id),
117
+ catch: (e) => e as Error,
118
+ })
119
+ if (errore.isError(channel)) {
112
120
  // Channel not in this guild, skip
121
+ continue
122
+ }
123
+ if (channel) {
124
+ projectsInGuild.push({ directory, channelId: channel_id })
113
125
  }
114
126
  }
115
127
 
@@ -14,6 +14,7 @@ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../dis
14
14
  import { extractTagsArrays } from '../xml.js'
15
15
  import { collectLastAssistantParts } from '../message-formatting.js'
16
16
  import { createLogger } from '../logger.js'
17
+ import * as errore from 'errore'
17
18
 
18
19
  const logger = createLogger('RESUME')
19
20
 
@@ -60,6 +61,10 @@ export async function handleResumeCommand({ command, appId }: CommandContext): P
60
61
 
61
62
  try {
62
63
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
64
+ if (errore.isError(getClient)) {
65
+ await command.editReply(getClient.message)
66
+ return
67
+ }
63
68
 
64
69
  const sessionResponse = await getClient().session.get({
65
70
  path: { id: sessionId },
@@ -168,6 +173,10 @@ export async function handleResumeAutocomplete({
168
173
 
169
174
  try {
170
175
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
176
+ if (errore.isError(getClient)) {
177
+ await interaction.respond([])
178
+ return
179
+ }
171
180
 
172
181
  const sessionsResponse = await getClient().session.list()
173
182
  if (!sessionsResponse.data) {
@@ -10,6 +10,7 @@ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
10
10
  import { extractTagsArrays } from '../xml.js'
11
11
  import { handleOpencodeSession } from '../session-handler.js'
12
12
  import { createLogger } from '../logger.js'
13
+ import * as errore from 'errore'
13
14
 
14
15
  const logger = createLogger('SESSION')
15
16
 
@@ -58,6 +59,10 @@ export async function handleSessionCommand({ command, appId }: CommandContext):
58
59
 
59
60
  try {
60
61
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
62
+ if (errore.isError(getClient)) {
63
+ await command.editReply(getClient.message)
64
+ return
65
+ }
61
66
 
62
67
  const files = filesString
63
68
  .split(',')
@@ -128,6 +133,10 @@ async function handleAgentAutocomplete({ interaction, appId }: AutocompleteConte
128
133
 
129
134
  try {
130
135
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
136
+ if (errore.isError(getClient)) {
137
+ await interaction.respond([])
138
+ return
139
+ }
131
140
 
132
141
  const agentsResponse = await getClient().app.agents({
133
142
  query: { directory: projectDirectory },
@@ -207,6 +216,10 @@ export async function handleSessionAutocomplete({
207
216
 
208
217
  try {
209
218
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
219
+ if (errore.isError(getClient)) {
220
+ await interaction.respond([])
221
+ return
222
+ }
210
223
 
211
224
  const response = await getClient().find.files({
212
225
  query: {
@@ -6,6 +6,7 @@ import { getDatabase } from '../database.js'
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js'
7
7
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
8
  import { createLogger } from '../logger.js'
9
+ import * as errore from 'errore'
9
10
 
10
11
  const logger = createLogger('SHARE')
11
12
 
@@ -63,8 +64,17 @@ export async function handleShareCommand({ command }: CommandContext): Promise<v
63
64
 
64
65
  const sessionId = row.session_id
65
66
 
67
+ const getClient = await initializeOpencodeForDirectory(directory)
68
+ if (errore.isError(getClient)) {
69
+ await command.reply({
70
+ content: `Failed to share session: ${getClient.message}`,
71
+ ephemeral: true,
72
+ flags: SILENT_MESSAGE_FLAGS,
73
+ })
74
+ return
75
+ }
76
+
66
77
  try {
67
- const getClient = await initializeOpencodeForDirectory(directory)
68
78
  const response = await getClient().session.share({
69
79
  path: { id: sessionId },
70
80
  })
@@ -6,6 +6,7 @@ import { getDatabase } from '../database.js'
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js'
7
7
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
8
8
  import { createLogger } from '../logger.js'
9
+ import * as errore from 'errore'
9
10
 
10
11
  const logger = createLogger('UNDO-REDO')
11
12
 
@@ -63,11 +64,15 @@ export async function handleUndoCommand({ command }: CommandContext): Promise<vo
63
64
 
64
65
  const sessionId = row.session_id
65
66
 
66
- try {
67
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
67
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
68
68
 
69
- const getClient = await initializeOpencodeForDirectory(directory)
69
+ const getClient = await initializeOpencodeForDirectory(directory)
70
+ if (errore.isError(getClient)) {
71
+ await command.editReply(`Failed to undo: ${getClient.message}`)
72
+ return
73
+ }
70
74
 
75
+ try {
71
76
  // Fetch messages to find the last assistant message
72
77
  const messagesResponse = await getClient().session.messages({
73
78
  path: { id: sessionId },
@@ -166,11 +171,15 @@ export async function handleRedoCommand({ command }: CommandContext): Promise<vo
166
171
 
167
172
  const sessionId = row.session_id
168
173
 
169
- try {
170
- await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
174
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
171
175
 
172
- const getClient = await initializeOpencodeForDirectory(directory)
176
+ const getClient = await initializeOpencodeForDirectory(directory)
177
+ if (errore.isError(getClient)) {
178
+ await command.editReply(`Failed to redo: ${getClient.message}`)
179
+ return
180
+ }
173
181
 
182
+ try {
174
183
  // Check if session has reverted state
175
184
  const sessionResponse = await getClient().session.get({
176
185
  path: { id: sessionId },
package/src/database.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import Database from 'better-sqlite3'
6
6
  import fs from 'node:fs'
7
7
  import path from 'node:path'
8
+ import * as errore from 'errore'
8
9
  import { createLogger } from './logger.js'
9
10
  import { getDataDir } from './config.js'
10
11
 
@@ -16,10 +17,14 @@ export function getDatabase(): Database.Database {
16
17
  if (!db) {
17
18
  const dataDir = getDataDir()
18
19
 
19
- try {
20
- fs.mkdirSync(dataDir, { recursive: true })
21
- } catch (error) {
22
- dbLogger.error(`Failed to create data directory ${dataDir}:`, error)
20
+ const mkdirError = errore.tryFn({
21
+ try: () => {
22
+ fs.mkdirSync(dataDir, { recursive: true })
23
+ },
24
+ catch: (e) => e as Error,
25
+ })
26
+ if (errore.isError(mkdirError)) {
27
+ dbLogger.error(`Failed to create data directory ${dataDir}:`, mkdirError.message)
23
28
  }
24
29
 
25
30
  const dbPath = path.join(dataDir, 'discord-sessions.db')
@@ -68,6 +73,14 @@ export function getDatabase(): Database.Database {
68
73
  // Column already exists, ignore
69
74
  }
70
75
 
76
+ // Table for threads that should auto-start a session (created by CLI without --notify-only)
77
+ db.exec(`
78
+ CREATE TABLE IF NOT EXISTS pending_auto_start (
79
+ thread_id TEXT PRIMARY KEY,
80
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
81
+ )
82
+ `)
83
+
71
84
  db.exec(`
72
85
  CREATE TABLE IF NOT EXISTS bot_api_keys (
73
86
  app_id TEXT PRIMARY KEY,
@@ -176,6 +189,15 @@ export function setSessionModel(sessionId: string, modelId: string): void {
176
189
  )
177
190
  }
178
191
 
192
+ /**
193
+ * Clear the model preference for a session.
194
+ * Used when switching agents so the agent's model takes effect.
195
+ */
196
+ export function clearSessionModel(sessionId: string): void {
197
+ const db = getDatabase()
198
+ db.prepare('DELETE FROM session_models WHERE session_id = ?').run(sessionId)
199
+ }
200
+
179
201
  /**
180
202
  * Get the agent preference for a channel.
181
203
  */