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
@@ -12,14 +12,19 @@ import path from 'node:path'
12
12
  import { getDatabase } from './database.js'
13
13
  import { extractTagsArrays } from './xml.js'
14
14
 
15
- export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
15
+ export async function ensureKimakiCategory(
16
+ guild: Guild,
17
+ botName?: string,
18
+ ): Promise<CategoryChannel> {
19
+ const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki'
20
+
16
21
  const existingCategory = guild.channels.cache.find(
17
22
  (channel): channel is CategoryChannel => {
18
23
  if (channel.type !== ChannelType.GuildCategory) {
19
24
  return false
20
25
  }
21
26
 
22
- return channel.name.toLowerCase() === 'kimaki'
27
+ return channel.name.toLowerCase() === categoryName.toLowerCase()
23
28
  },
24
29
  )
25
30
 
@@ -28,19 +33,24 @@ export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChanne
28
33
  }
29
34
 
30
35
  return guild.channels.create({
31
- name: 'Kimaki',
36
+ name: categoryName,
32
37
  type: ChannelType.GuildCategory,
33
38
  })
34
39
  }
35
40
 
36
- export async function ensureKimakiAudioCategory(guild: Guild): Promise<CategoryChannel> {
41
+ export async function ensureKimakiAudioCategory(
42
+ guild: Guild,
43
+ botName?: string,
44
+ ): Promise<CategoryChannel> {
45
+ const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
46
+
37
47
  const existingCategory = guild.channels.cache.find(
38
48
  (channel): channel is CategoryChannel => {
39
49
  if (channel.type !== ChannelType.GuildCategory) {
40
50
  return false
41
51
  }
42
52
 
43
- return channel.name.toLowerCase() === 'kimaki audio'
53
+ return channel.name.toLowerCase() === categoryName.toLowerCase()
44
54
  },
45
55
  )
46
56
 
@@ -49,7 +59,7 @@ export async function ensureKimakiAudioCategory(guild: Guild): Promise<CategoryC
49
59
  }
50
60
 
51
61
  return guild.channels.create({
52
- name: 'Kimaki Audio',
62
+ name: categoryName,
53
63
  type: ChannelType.GuildCategory,
54
64
  })
55
65
  }
@@ -58,10 +68,12 @@ export async function createProjectChannels({
58
68
  guild,
59
69
  projectDirectory,
60
70
  appId,
71
+ botName,
61
72
  }: {
62
73
  guild: Guild
63
74
  projectDirectory: string
64
75
  appId: string
76
+ botName?: string
65
77
  }): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
66
78
  const baseName = path.basename(projectDirectory)
67
79
  const channelName = `${baseName}`
@@ -69,8 +81,8 @@ export async function createProjectChannels({
69
81
  .replace(/[^a-z0-9-]/g, '-')
70
82
  .slice(0, 100)
71
83
 
72
- const kimakiCategory = await ensureKimakiCategory(guild)
73
- const kimakiAudioCategory = await ensureKimakiAudioCategory(guild)
84
+ const kimakiCategory = await ensureKimakiCategory(guild, botName)
85
+ const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
74
86
 
75
87
  const textChannel = await guild.channels.create({
76
88
  name: channelName,
package/src/cli.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  createProjectChannels,
28
28
  type ChannelWithTags,
29
29
  } from './discord-bot.js'
30
- import type { OpencodeClient } from '@opencode-ai/sdk'
30
+ import type { OpencodeClient, Command as OpencodeCommand } from '@opencode-ai/sdk'
31
31
  import {
32
32
  Events,
33
33
  ChannelType,
@@ -82,13 +82,14 @@ async function killProcessOnPort(port: number): Promise<boolean> {
82
82
  // Filter out our own PID and take the first (oldest)
83
83
  const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
84
84
  if (targetPid) {
85
- cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`)
86
- process.kill(parseInt(targetPid, 10), 'SIGKILL')
85
+ const pid = parseInt(targetPid, 10)
86
+ cliLogger.log(`Stopping existing kimaki process (PID: ${pid})`)
87
+ process.kill(pid, 'SIGKILL')
87
88
  return true
88
89
  }
89
90
  }
90
- } catch {
91
- // Failed to kill, continue anyway
91
+ } catch (e) {
92
+ cliLogger.debug(`Failed to kill process on port ${port}:`, e)
92
93
  }
93
94
  return false
94
95
  }
@@ -105,7 +106,7 @@ async function checkSingleInstance(): Promise<void> {
105
106
  await new Promise((resolve) => { setTimeout(resolve, 500) })
106
107
  }
107
108
  } catch {
108
- // Connection refused means no instance running, continue
109
+ cliLogger.debug('No other kimaki instance detected on lock port')
109
110
  }
110
111
  }
111
112
 
@@ -152,7 +153,10 @@ type CliOptions = {
152
153
  addChannels?: boolean
153
154
  }
154
155
 
155
- async function registerCommands(token: string, appId: string) {
156
+ // Commands to skip when registering user commands (reserved names)
157
+ const SKIP_USER_COMMANDS = ['init']
158
+
159
+ async function registerCommands(token: string, appId: string, userCommands: OpencodeCommand[] = []) {
156
160
  const commands = [
157
161
  new SlashCommandBuilder()
158
162
  .setName('resume')
@@ -231,6 +235,10 @@ async function registerCommands(token: string, appId: string) {
231
235
  .setName('abort')
232
236
  .setDescription('Abort the current OpenCode request in this thread')
233
237
  .toJSON(),
238
+ new SlashCommandBuilder()
239
+ .setName('stop')
240
+ .setDescription('Abort the current OpenCode request in this thread')
241
+ .toJSON(),
234
242
  new SlashCommandBuilder()
235
243
  .setName('share')
236
244
  .setDescription('Share the current session as a public URL')
@@ -243,6 +251,10 @@ async function registerCommands(token: string, appId: string) {
243
251
  .setName('model')
244
252
  .setDescription('Set the preferred model for this channel or session')
245
253
  .toJSON(),
254
+ new SlashCommandBuilder()
255
+ .setName('agent')
256
+ .setDescription('Set the preferred agent for this channel or session')
257
+ .toJSON(),
246
258
  new SlashCommandBuilder()
247
259
  .setName('queue')
248
260
  .setDescription('Queue a message to be sent after the current response finishes')
@@ -269,6 +281,30 @@ async function registerCommands(token: string, appId: string) {
269
281
  .toJSON(),
270
282
  ]
271
283
 
284
+ // Add user-defined commands with -cmd suffix
285
+ for (const cmd of userCommands) {
286
+ if (SKIP_USER_COMMANDS.includes(cmd.name)) {
287
+ continue
288
+ }
289
+
290
+ const commandName = `${cmd.name}-cmd`
291
+ const description = cmd.description || `Run /${cmd.name} command`
292
+
293
+ commands.push(
294
+ new SlashCommandBuilder()
295
+ .setName(commandName)
296
+ .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
297
+ .addStringOption((option) => {
298
+ option
299
+ .setName('arguments')
300
+ .setDescription('Arguments to pass to the command')
301
+ .setRequired(false)
302
+ return option
303
+ })
304
+ .toJSON(),
305
+ )
306
+ }
307
+
272
308
  const rest = new REST().setToken(token)
273
309
 
274
310
  try {
@@ -686,6 +722,7 @@ async function run({ restart, addChannels }: CliOptions) {
686
722
  guild: targetGuild,
687
723
  projectDirectory: project.worktree,
688
724
  appId,
725
+ botName: discordClient.user?.username,
689
726
  })
690
727
 
691
728
  createdChannels.push({
@@ -709,8 +746,37 @@ async function run({ restart, addChannels }: CliOptions) {
709
746
  }
710
747
  }
711
748
 
749
+ // Fetch user-defined commands using the already-running server
750
+ const allUserCommands: OpencodeCommand[] = []
751
+ try {
752
+ const commandsResponse = await getClient().command.list({
753
+ query: { directory: currentDir },
754
+ })
755
+ if (commandsResponse.data) {
756
+ allUserCommands.push(...commandsResponse.data)
757
+ }
758
+ } catch {
759
+ // Ignore errors fetching commands
760
+ }
761
+
762
+ // Log available user commands
763
+ const registrableCommands = allUserCommands.filter(
764
+ (cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
765
+ )
766
+
767
+ if (registrableCommands.length > 0) {
768
+ const commandList = registrableCommands
769
+ .map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
770
+ .join('\n')
771
+
772
+ note(
773
+ `Found ${registrableCommands.length} user-defined command(s):\n${commandList}`,
774
+ 'OpenCode Commands',
775
+ )
776
+ }
777
+
712
778
  cliLogger.log('Registering slash commands asynchronously...')
713
- void registerCommands(token, appId)
779
+ void registerCommands(token, appId, allUserCommands)
714
780
  .then(() => {
715
781
  cliLogger.log('Slash commands registered!')
716
782
  })
@@ -67,6 +67,7 @@ export async function handleAddProjectCommand({
67
67
  guild,
68
68
  projectDirectory: directory,
69
69
  appId,
70
+ botName: command.client.user?.username,
70
71
  })
71
72
 
72
73
  await command.editReply(
@@ -0,0 +1,201 @@
1
+ // /agent command - Set the preferred agent for this channel or session.
2
+
3
+ import {
4
+ ChatInputCommandInteraction,
5
+ StringSelectMenuInteraction,
6
+ StringSelectMenuBuilder,
7
+ ActionRowBuilder,
8
+ ChannelType,
9
+ type ThreadChannel,
10
+ type TextChannel,
11
+ } from 'discord.js'
12
+ import crypto from 'node:crypto'
13
+ import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js'
14
+ import { initializeOpencodeForDirectory } from '../opencode.js'
15
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
16
+ import { createLogger } from '../logger.js'
17
+
18
+ const agentLogger = createLogger('AGENT')
19
+
20
+ const pendingAgentContexts = new Map<string, {
21
+ dir: string
22
+ channelId: string
23
+ sessionId?: string
24
+ isThread: boolean
25
+ }>()
26
+
27
+ export async function handleAgentCommand({
28
+ interaction,
29
+ appId,
30
+ }: {
31
+ interaction: ChatInputCommandInteraction
32
+ appId: string
33
+ }): Promise<void> {
34
+ await interaction.deferReply({ ephemeral: true })
35
+
36
+ runModelMigrations()
37
+
38
+ const channel = interaction.channel
39
+
40
+ if (!channel) {
41
+ await interaction.editReply({ content: 'This command can only be used in a channel' })
42
+ return
43
+ }
44
+
45
+ const isThread = [
46
+ ChannelType.PublicThread,
47
+ ChannelType.PrivateThread,
48
+ ChannelType.AnnouncementThread,
49
+ ].includes(channel.type)
50
+
51
+ let projectDirectory: string | undefined
52
+ let channelAppId: string | undefined
53
+ let targetChannelId: string
54
+ let sessionId: string | undefined
55
+
56
+ if (isThread) {
57
+ const thread = channel as ThreadChannel
58
+ const textChannel = await resolveTextChannel(thread)
59
+ const metadata = getKimakiMetadata(textChannel)
60
+ projectDirectory = metadata.projectDirectory
61
+ channelAppId = metadata.channelAppId
62
+ targetChannelId = textChannel?.id || channel.id
63
+
64
+ const row = getDatabase()
65
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
66
+ .get(thread.id) as { session_id: string } | undefined
67
+ sessionId = row?.session_id
68
+ } else if (channel.type === ChannelType.GuildText) {
69
+ const textChannel = channel as TextChannel
70
+ const metadata = getKimakiMetadata(textChannel)
71
+ projectDirectory = metadata.projectDirectory
72
+ channelAppId = metadata.channelAppId
73
+ targetChannelId = channel.id
74
+ } else {
75
+ await interaction.editReply({ content: 'This command can only be used in text channels or threads' })
76
+ return
77
+ }
78
+
79
+ if (channelAppId && channelAppId !== appId) {
80
+ await interaction.editReply({ content: 'This channel is not configured for this bot' })
81
+ return
82
+ }
83
+
84
+ if (!projectDirectory) {
85
+ await interaction.editReply({ content: 'This channel is not configured with a project directory' })
86
+ return
87
+ }
88
+
89
+ try {
90
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
91
+
92
+ const agentsResponse = await getClient().app.agents({
93
+ query: { directory: projectDirectory },
94
+ })
95
+
96
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
97
+ await interaction.editReply({ content: 'No agents available' })
98
+ return
99
+ }
100
+
101
+ const agents = agentsResponse.data
102
+ .filter((a) => a.mode === 'primary' || a.mode === 'all')
103
+ .slice(0, 25)
104
+
105
+ if (agents.length === 0) {
106
+ await interaction.editReply({ content: 'No primary agents available' })
107
+ return
108
+ }
109
+
110
+ const contextHash = crypto.randomBytes(8).toString('hex')
111
+ pendingAgentContexts.set(contextHash, {
112
+ dir: projectDirectory,
113
+ channelId: targetChannelId,
114
+ sessionId,
115
+ isThread,
116
+ })
117
+
118
+ const options = agents.map((agent) => ({
119
+ label: agent.name.slice(0, 100),
120
+ value: agent.name,
121
+ description: (agent.description || `${agent.mode} agent`).slice(0, 100),
122
+ }))
123
+
124
+ const selectMenu = new StringSelectMenuBuilder()
125
+ .setCustomId(`agent_select:${contextHash}`)
126
+ .setPlaceholder('Select an agent')
127
+ .addOptions(options)
128
+
129
+ const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
130
+
131
+ await interaction.editReply({
132
+ content: '**Set Agent Preference**\nSelect an agent:',
133
+ components: [actionRow],
134
+ })
135
+ } catch (error) {
136
+ agentLogger.error('Error loading agents:', error)
137
+ await interaction.editReply({
138
+ content: `Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`,
139
+ })
140
+ }
141
+ }
142
+
143
+ export async function handleAgentSelectMenu(
144
+ interaction: StringSelectMenuInteraction
145
+ ): Promise<void> {
146
+ const customId = interaction.customId
147
+
148
+ if (!customId.startsWith('agent_select:')) {
149
+ return
150
+ }
151
+
152
+ await interaction.deferUpdate()
153
+
154
+ const contextHash = customId.replace('agent_select:', '')
155
+ const context = pendingAgentContexts.get(contextHash)
156
+
157
+ if (!context) {
158
+ await interaction.editReply({
159
+ content: 'Selection expired. Please run /agent again.',
160
+ components: [],
161
+ })
162
+ return
163
+ }
164
+
165
+ const selectedAgent = interaction.values[0]
166
+ if (!selectedAgent) {
167
+ await interaction.editReply({
168
+ content: 'No agent selected',
169
+ components: [],
170
+ })
171
+ return
172
+ }
173
+
174
+ try {
175
+ if (context.isThread && context.sessionId) {
176
+ setSessionAgent(context.sessionId, selectedAgent)
177
+ agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`)
178
+
179
+ await interaction.editReply({
180
+ content: `Agent preference set for this session: **${selectedAgent}**`,
181
+ components: [],
182
+ })
183
+ } else {
184
+ setChannelAgent(context.channelId, selectedAgent)
185
+ agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`)
186
+
187
+ await interaction.editReply({
188
+ content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
189
+ components: [],
190
+ })
191
+ }
192
+
193
+ pendingAgentContexts.delete(contextHash)
194
+ } catch (error) {
195
+ agentLogger.error('Error saving agent preference:', error)
196
+ await interaction.editReply({
197
+ content: `Failed to save agent preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
198
+ components: [],
199
+ })
200
+ }
201
+ }