kimaki 0.4.36 โ†’ 0.4.38

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/src/database.ts CHANGED
@@ -61,6 +61,21 @@ export function getDatabase(): Database.Database {
61
61
  )
62
62
  `)
63
63
 
64
+ // Migration: add app_id column to channel_directories for multi-bot support
65
+ try {
66
+ db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`)
67
+ } catch {
68
+ // Column already exists, ignore
69
+ }
70
+
71
+ // Table for threads that should auto-start a session (created by CLI without --notify-only)
72
+ db.exec(`
73
+ CREATE TABLE IF NOT EXISTS pending_auto_start (
74
+ thread_id TEXT PRIMARY KEY,
75
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
76
+ )
77
+ `)
78
+
64
79
  db.exec(`
65
80
  CREATE TABLE IF NOT EXISTS bot_api_keys (
66
81
  app_id TEXT PRIMARY KEY,
@@ -169,6 +184,15 @@ export function setSessionModel(sessionId: string, modelId: string): void {
169
184
  )
170
185
  }
171
186
 
187
+ /**
188
+ * Clear the model preference for a session.
189
+ * Used when switching agents so the agent's model takes effect.
190
+ */
191
+ export function clearSessionModel(sessionId: string): void {
192
+ const db = getDatabase()
193
+ db.prepare('DELETE FROM session_models WHERE session_id = ?').run(sessionId)
194
+ }
195
+
172
196
  /**
173
197
  * Get the agent preference for a channel.
174
198
  */
@@ -187,17 +187,6 @@ export async function startDiscordBot({
187
187
  const thread = channel as ThreadChannel
188
188
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
189
189
 
190
- const row = getDatabase()
191
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
192
- .get(thread.id) as { session_id: string } | undefined
193
-
194
- if (!row) {
195
- discordLogger.log(`No session found for thread ${thread.id}`)
196
- return
197
- }
198
-
199
- voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`)
200
-
201
190
  const parent = thread.parent as TextChannel | null
202
191
  let projectDirectory: string | undefined
203
192
  let channelAppId: string | undefined
@@ -228,6 +217,37 @@ export async function startDiscordBot({
228
217
  return
229
218
  }
230
219
 
220
+ const row = getDatabase()
221
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
222
+ .get(thread.id) as { session_id: string } | undefined
223
+
224
+ // No existing session - start a new one (e.g., replying to a notification thread)
225
+ if (!row) {
226
+ discordLogger.log(`No session for thread ${thread.id}, starting new session`)
227
+
228
+ if (!projectDirectory) {
229
+ discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`)
230
+ return
231
+ }
232
+
233
+ // Include starter message as context for the session
234
+ let prompt = message.content
235
+ const starterMessage = await thread.fetchStarterMessage().catch(() => null)
236
+ if (starterMessage?.content && starterMessage.content !== message.content) {
237
+ prompt = `Context from thread:\n${starterMessage.content}\n\nUser request:\n${message.content}`
238
+ }
239
+
240
+ await handleOpencodeSession({
241
+ prompt,
242
+ thread,
243
+ projectDirectory,
244
+ channelId: parent?.id || '',
245
+ })
246
+ return
247
+ }
248
+
249
+ voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`)
250
+
231
251
  let messageContent = message.content || ''
232
252
 
233
253
  let currentSessionContext: string | undefined
@@ -393,42 +413,42 @@ export async function startDiscordBot({
393
413
  }
394
414
  })
395
415
 
396
- // Magic prefix used by `kimaki start-session` CLI command to initiate sessions
397
- const BOT_SESSION_PREFIX = '๐Ÿค– **Bot-initiated session**'
398
-
399
- // Handle bot-initiated threads created by `kimaki start-session`
416
+ // Handle bot-initiated threads created by `kimaki send` (without --notify-only)
400
417
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
401
418
  try {
402
419
  if (!newlyCreated) {
403
420
  return
404
421
  }
405
422
 
423
+ // Check if this thread is marked for auto-start in the database
424
+ const db = getDatabase()
425
+ const pendingRow = db
426
+ .prepare('SELECT thread_id FROM pending_auto_start WHERE thread_id = ?')
427
+ .get(thread.id) as { thread_id: string } | undefined
428
+
429
+ if (!pendingRow) {
430
+ return // Not a CLI-initiated auto-start thread
431
+ }
432
+
433
+ // Remove from pending table
434
+ db.prepare('DELETE FROM pending_auto_start WHERE thread_id = ?').run(thread.id)
435
+
436
+ discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
437
+
406
438
  // Only handle threads in text channels
407
439
  const parent = thread.parent as TextChannel | null
408
440
  if (!parent || parent.type !== ChannelType.GuildText) {
409
441
  return
410
442
  }
411
443
 
412
- // Get the starter message to check for magic prefix
444
+ // Get the starter message for the prompt
413
445
  const starterMessage = await thread.fetchStarterMessage().catch(() => null)
414
446
  if (!starterMessage) {
415
447
  discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
416
448
  return
417
449
  }
418
450
 
419
- // Only handle messages from this bot with the magic prefix
420
- if (starterMessage.author.id !== discordClient.user?.id) {
421
- return
422
- }
423
-
424
- if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
425
- return
426
- }
427
-
428
- discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
429
-
430
- // Extract the prompt (everything after the prefix)
431
- const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim()
451
+ const prompt = starterMessage.content.trim()
432
452
  if (!prompt) {
433
453
  discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
434
454
  return
@@ -20,7 +20,7 @@ import {
20
20
  handleProviderSelectMenu,
21
21
  handleModelSelectMenu,
22
22
  } from './commands/model.js'
23
- import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js'
23
+ import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand } from './commands/agent.js'
24
24
  import { handleAskQuestionSelectMenu } from './commands/ask-question.js'
25
25
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js'
26
26
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
@@ -136,6 +136,12 @@ export function registerInteractionHandler({
136
136
  return
137
137
  }
138
138
 
139
+ // Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
140
+ if (interaction.commandName.endsWith('-agent') && interaction.commandName !== 'agent') {
141
+ await handleQuickAgentCommand({ command: interaction, appId })
142
+ return
143
+ }
144
+
139
145
  // Handle user-defined commands (ending with -cmd suffix)
140
146
  if (interaction.commandName.endsWith('-cmd')) {
141
147
  await handleUserCommand({ command: interaction, appId })
@@ -641,6 +641,39 @@ export async function handleOpencodeSession({
641
641
  requestId: questionRequest.id,
642
642
  input: { questions: questionRequest.questions },
643
643
  })
644
+
645
+ // Process queued messages if any - queued message will cancel the pending question
646
+ const queue = messageQueue.get(thread.id)
647
+ if (queue && queue.length > 0) {
648
+ const nextMessage = queue.shift()!
649
+ if (queue.length === 0) {
650
+ messageQueue.delete(thread.id)
651
+ }
652
+
653
+ sessionLogger.log(
654
+ `[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`,
655
+ )
656
+
657
+ await sendThreadMessage(
658
+ thread,
659
+ `ยป **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`,
660
+ )
661
+
662
+ // handleOpencodeSession will call cancelPendingQuestion, which cancels the dropdown
663
+ setImmediate(() => {
664
+ handleOpencodeSession({
665
+ prompt: nextMessage.prompt,
666
+ thread,
667
+ projectDirectory: directory,
668
+ images: nextMessage.images,
669
+ channelId,
670
+ }).catch(async (e) => {
671
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, e)
672
+ const errorMsg = e instanceof Error ? e.message : String(e)
673
+ await sendThreadMessage(thread, `โœ— Queued message failed: ${errorMsg.slice(0, 200)}`)
674
+ })
675
+ })
676
+ }
644
677
  } else if (event.type === 'session.idle') {
645
678
  // Session is done processing - abort to signal completion
646
679
  if (event.properties.sessionID === session.id) {
@@ -774,10 +807,23 @@ export async function handleOpencodeSession({
774
807
  const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]
775
808
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
776
809
 
810
+ // Get agent preference: session-level overrides channel-level
811
+ const agentPreference =
812
+ getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
813
+ if (agentPreference) {
814
+ sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
815
+ }
816
+
777
817
  // Get model preference: session-level overrides channel-level
818
+ // BUT: if an agent is set, don't pass model param so the agent's model takes effect
778
819
  const modelPreference =
779
820
  getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
780
821
  const modelParam = (() => {
822
+ // When an agent is set, let the agent's model config take effect
823
+ if (agentPreference) {
824
+ sessionLogger.log(`[MODEL] Skipping model param, agent "${agentPreference}" controls model`)
825
+ return undefined
826
+ }
781
827
  if (!modelPreference) {
782
828
  return undefined
783
829
  }
@@ -790,13 +836,6 @@ export async function handleOpencodeSession({
790
836
  return { providerID, modelID }
791
837
  })()
792
838
 
793
- // Get agent preference: session-level overrides channel-level
794
- const agentPreference =
795
- getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined)
796
- if (agentPreference) {
797
- sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`)
798
- }
799
-
800
839
  // Use session.command API for slash commands, session.prompt for regular messages
801
840
  const response = command
802
841
  ? await getClient().session.command({
@@ -850,9 +889,8 @@ export async function handleOpencodeSession({
850
889
 
851
890
  return { sessionID: session.id, result: response.data, port }
852
891
  } catch (error) {
853
- sessionLogger.error(`ERROR: Failed to send prompt:`, error)
854
-
855
892
  if (!isAbortError(error, abortController.signal)) {
893
+ sessionLogger.error(`ERROR: Failed to send prompt:`, error)
856
894
  abortController.abort('error')
857
895
 
858
896
  if (originalMessage) {
@@ -36,7 +36,11 @@ ${
36
36
 
37
37
  To start a new thread/session in this channel programmatically, run:
38
38
 
39
- npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
39
+ npx -y kimaki send --channel ${channelId} --prompt "your prompt here"
40
+
41
+ Use --notify-only to create a notification thread without starting an AI session:
42
+
43
+ npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
40
44
 
41
45
  This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
42
46
  `