kimaki 0.4.25 → 0.4.27

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 (52) hide show
  1. package/dist/acp-client.test.js +149 -0
  2. package/dist/channel-management.js +11 -9
  3. package/dist/cli.js +58 -18
  4. package/dist/commands/add-project.js +1 -0
  5. package/dist/commands/agent.js +152 -0
  6. package/dist/commands/ask-question.js +184 -0
  7. package/dist/commands/model.js +23 -4
  8. package/dist/commands/permissions.js +101 -105
  9. package/dist/commands/session.js +1 -3
  10. package/dist/commands/user-command.js +145 -0
  11. package/dist/database.js +51 -0
  12. package/dist/discord-bot.js +32 -32
  13. package/dist/discord-utils.js +71 -14
  14. package/dist/interaction-handler.js +25 -8
  15. package/dist/logger.js +43 -5
  16. package/dist/markdown.js +104 -0
  17. package/dist/markdown.test.js +31 -1
  18. package/dist/message-formatting.js +72 -22
  19. package/dist/message-formatting.test.js +73 -0
  20. package/dist/opencode.js +70 -16
  21. package/dist/session-handler.js +142 -66
  22. package/dist/system-message.js +4 -51
  23. package/dist/voice-handler.js +18 -8
  24. package/dist/voice.js +28 -12
  25. package/package.json +14 -13
  26. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  27. package/src/__snapshots__/compact-session-context.md +47 -0
  28. package/src/channel-management.ts +20 -8
  29. package/src/cli.ts +73 -19
  30. package/src/commands/add-project.ts +1 -0
  31. package/src/commands/agent.ts +201 -0
  32. package/src/commands/ask-question.ts +277 -0
  33. package/src/commands/fork.ts +1 -2
  34. package/src/commands/model.ts +24 -4
  35. package/src/commands/permissions.ts +139 -114
  36. package/src/commands/session.ts +1 -3
  37. package/src/commands/user-command.ts +178 -0
  38. package/src/database.ts +61 -0
  39. package/src/discord-bot.ts +36 -33
  40. package/src/discord-utils.ts +76 -14
  41. package/src/interaction-handler.ts +31 -10
  42. package/src/logger.ts +47 -10
  43. package/src/markdown.test.ts +45 -1
  44. package/src/markdown.ts +132 -0
  45. package/src/message-formatting.test.ts +81 -0
  46. package/src/message-formatting.ts +93 -25
  47. package/src/opencode.ts +80 -21
  48. package/src/session-handler.ts +190 -97
  49. package/src/system-message.ts +4 -51
  50. package/src/voice-handler.ts +20 -9
  51. package/src/voice.ts +32 -13
  52. package/LICENSE +0 -21
package/dist/voice.js CHANGED
@@ -23,7 +23,8 @@ async function runGrep({ pattern, directory, }) {
23
23
  .join('\n');
24
24
  return output.slice(0, 2000);
25
25
  }
26
- catch {
26
+ catch (e) {
27
+ voiceLogger.error('grep search failed:', e);
27
28
  return 'grep search failed';
28
29
  }
29
30
  }
@@ -204,7 +205,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
204
205
  });
205
206
  }
206
207
  }
207
- export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, sessionMessages, }) {
208
+ export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, currentSessionContext, lastSessionContext, }) {
208
209
  try {
209
210
  const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
210
211
  if (!apiKey) {
@@ -228,6 +229,21 @@ export async function transcribeAudio({ audio, prompt, language, temperature, ge
228
229
  throw new Error('Invalid audio format');
229
230
  }
230
231
  const languageHint = language ? `The audio is in ${language}.\n\n` : '';
232
+ // build session context section
233
+ const sessionContextParts = [];
234
+ if (lastSessionContext) {
235
+ sessionContextParts.push(`<last_session>
236
+ ${lastSessionContext}
237
+ </last_session>`);
238
+ }
239
+ if (currentSessionContext) {
240
+ sessionContextParts.push(`<current_session>
241
+ ${currentSessionContext}
242
+ </current_session>`);
243
+ }
244
+ const sessionContextSection = sessionContextParts.length > 0
245
+ ? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
246
+ : '';
231
247
  const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
232
248
 
233
249
  CRITICAL REQUIREMENT: You MUST call the "transcriptionResult" tool to complete this task.
@@ -241,29 +257,29 @@ This is a software development environment. The speaker is giving instructions t
241
257
  - File paths, function names, CLI commands, package names, API endpoints
242
258
 
243
259
  RULES:
244
- 1. You have LIMITED tool calls - use grep/glob sparingly, call them in parallel
245
- 2. If audio is unclear, transcribe your best interpretation
246
- 3. If audio seems silent/empty, call transcriptionResult with "[inaudible audio]"
247
- 4. When warned about remaining steps, STOP searching and call transcriptionResult immediately
260
+ 1. If audio is unclear, transcribe your best interpretation, interpreting words event with strong accents are present, identifying the accent being used first so you can guess what the words meawn
261
+ 2. If audio seems silent/empty, call transcriptionResult with "[inaudible audio]"
262
+ 3. Use the session context below to understand technical terms, file names, function names mentioned
248
263
 
249
264
  Common corrections (apply without tool calls):
250
265
  - "reacked" → "React", "jason" → "JSON", "get hub" → "GitHub", "no JS" → "Node.js", "dacker" → "Docker"
251
266
 
252
- Project context for reference:
253
- <context>
267
+ Project file structure:
268
+ <file_tree>
254
269
  ${prompt}
255
- </context>
256
- ${sessionMessages ? `\nRecent session messages:\n<session_messages>\n${sessionMessages}\n</session_messages>` : ''}
270
+ </file_tree>
271
+ ${sessionContextSection}
257
272
 
258
273
  REMEMBER: Call "transcriptionResult" tool with your transcription. This is mandatory.
259
274
 
260
275
  Note: "critique" is a CLI tool for showing diffs in the browser.`;
261
- const hasDirectory = directory && directory.trim().length > 0;
276
+ // const hasDirectory = directory && directory.trim().length > 0
262
277
  const tools = [
263
278
  {
264
279
  functionDeclarations: [
265
280
  transcriptionResultToolDeclaration,
266
- ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
281
+ // grep/glob disabled - was causing transcription to hang
282
+ // ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
267
283
  ],
268
284
  },
269
285
  ];
package/package.json CHANGED
@@ -2,7 +2,17 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.25",
5
+ "version": "0.4.27",
6
+ "scripts": {
7
+ "dev": "tsx --env-file .env src/cli.ts",
8
+ "prepublishOnly": "pnpm tsc",
9
+ "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
10
+ "watch": "tsx scripts/watch-session.ts",
11
+ "test:events": "tsx test-events.ts",
12
+ "pcm-to-mp3": "bun scripts/pcm-to-mp3",
13
+ "test:send": "tsx send-test-message.ts",
14
+ "register-commands": "tsx scripts/register-commands.ts"
15
+ },
6
16
  "repository": "https://github.com/remorses/kimaki",
7
17
  "bin": "bin.js",
8
18
  "files": [
@@ -11,7 +21,7 @@
11
21
  "bin.js"
12
22
  ],
13
23
  "devDependencies": {
14
- "@opencode-ai/plugin": "^1.0.193",
24
+ "@opencode-ai/plugin": "^1.1.12",
15
25
  "@types/better-sqlite3": "^7.6.13",
16
26
  "@types/bun": "latest",
17
27
  "@types/js-yaml": "^4.0.9",
@@ -25,7 +35,7 @@
25
35
  "@discordjs/opus": "^0.10.0",
26
36
  "@discordjs/voice": "^0.19.0",
27
37
  "@google/genai": "^1.34.0",
28
- "@opencode-ai/sdk": "^1.0.193",
38
+ "@opencode-ai/sdk": "^1.1.12",
29
39
  "@purinton/resampler": "^1.0.4",
30
40
  "@snazzah/davey": "^0.1.6",
31
41
  "ai": "^5.0.114",
@@ -45,14 +55,5 @@
45
55
  "string-dedent": "^3.0.2",
46
56
  "undici": "^7.16.0",
47
57
  "zod": "^4.2.1"
48
- },
49
- "scripts": {
50
- "dev": "tsx --env-file .env src/cli.ts",
51
- "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
52
- "watch": "tsx scripts/watch-session.ts",
53
- "test:events": "tsx test-events.ts",
54
- "pcm-to-mp3": "bun scripts/pcm-to-mp3",
55
- "test:send": "tsx send-test-message.ts",
56
- "register-commands": "tsx scripts/register-commands.ts"
57
58
  }
58
- }
59
+ }
@@ -0,0 +1,35 @@
1
+ [Assistant]: Still not showing. The session might not have a `system` field and the first assistant message might not have `synthetic: true`. Let me add debug logging to see what's available:
2
+
3
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
4
+ if (includeSystemPrompt) {
5
+ // try session.system fie, newString= // Get system prompt if requested
6
+ if (includeSystemPrompt) {
7
+ // try session.system fie
8
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= } else if (messages.length > 0) {
9
+ // fallback: look for synthetic text part in first a, newString= } else if (messages.length > 0) {
10
+ // fallback: look for synthetic text part in first a
11
+ [Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 | tail -30, description=run test and check output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
12
+ [Tool bash]: command=grep -i "session keys\|text parts" tmp/kimaki.log | tail -10, description=check debug output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord
13
+ [Assistant]: Session doesn't have a `system` field. Let me fix the debug logging:
14
+
15
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
16
+ , newString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
17
+
18
+ [Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 && grep -i "text parts" , description=run test and check debug, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
19
+ [Assistant]: I see - the first assistant text part has **no `synthetic` field** (it's `undefined`), and it's only 44 chars. The session doesn't expose the system prompt.
20
+
21
+ Let me remove debug logging and fix the test:
22
+
23
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
24
+ if (includeSystemPrompt) {
25
+ // try session.system fie, newString= // Get system prompt if requested
26
+ // Note: OpenCode SDK doesn't expose system prompt directl
27
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= try {
28
+ // fetch session info to get system prompt
29
+ const sessionResponse = await client.sess, newString= try {
30
+ const messagesResponse = await client.session.messages({
31
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.test.ts, oldString= expect(context).toBeTruthy()
32
+ expect(context.length).toBeGreaterThan(0)
33
+ // should have tool cal, newString= expect(context).toBeTruthy()
34
+ expect(context.length).toBeGreaterThan(0)
35
+ // should have tool cal
@@ -0,0 +1,47 @@
1
+ [Tool bash]: command=grep -A5 -B5 "Session" node_modules/@opencode-ai/.ignored_sdk/dist/gen/core/types.gen.d.ts 2>/dev/nu, description=check session types, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord
2
+ [Tool read]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, offset=248, limit=35
3
+ [Assistant]: Let me update the code to also look for system role messages and fetch session info:
4
+
5
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= try {
6
+ const messagesResponse = await client.session.messages({
7
+ path: { id: sessionId },
8
+ , newString= try {
9
+ // fetch session info to get system prompt
10
+ const sessionResponse = await client.sess
11
+ [Tool bash]: command=pnpm vitest run -u --testNamePattern "getCompactSessionContext", description=run tests to check system prompt, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
12
+ [Tool bash]: command=head -30 discord/src/__snapshots__/compact-session-context.md, description=check if system prompt is in snapshot
13
+ [Assistant]: Still not showing. The session might not have a `system` field and the first assistant message might not have `synthetic: true`. Let me add debug logging to see what's available:
14
+
15
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
16
+ if (includeSystemPrompt) {
17
+ // try session.system fie, newString= // Get system prompt if requested
18
+ if (includeSystemPrompt) {
19
+ // try session.system fie
20
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= } else if (messages.length > 0) {
21
+ // fallback: look for synthetic text part in first a, newString= } else if (messages.length > 0) {
22
+ // fallback: look for synthetic text part in first a
23
+ [Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 | tail -30, description=run test and check output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
24
+ [Tool bash]: command=grep -i "session keys\|text parts" tmp/kimaki.log | tail -10, description=check debug output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord
25
+ [Assistant]: Session doesn't have a `system` field. Let me fix the debug logging:
26
+
27
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
28
+ , newString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
29
+
30
+ [Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 && grep -i "text parts" , description=run test and check debug, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
31
+ [Assistant]: I see - the first assistant text part has **no `synthetic` field** (it's `undefined`), and it's only 44 chars. The session doesn't expose the system prompt.
32
+
33
+ Let me remove debug logging and fix the test:
34
+
35
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
36
+ if (includeSystemPrompt) {
37
+ // try session.system fie, newString= // Get system prompt if requested
38
+ // Note: OpenCode SDK doesn't expose system prompt directl
39
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= try {
40
+ // fetch session info to get system prompt
41
+ const sessionResponse = await client.sess, newString= try {
42
+ const messagesResponse = await client.session.messages({
43
+ [Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.test.ts, oldString= expect(context).toBeTruthy()
44
+ expect(context.length).toBeGreaterThan(0)
45
+ // should have tool cal, newString= expect(context).toBeTruthy()
46
+ expect(context.length).toBeGreaterThan(0)
47
+ // should have tool cal
@@ -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')
@@ -216,19 +220,11 @@ async function registerCommands(token: string, appId: string) {
216
220
  })
217
221
  .toJSON(),
218
222
  new SlashCommandBuilder()
219
- .setName('accept')
220
- .setDescription('Accept a pending permission request (this request only)')
221
- .toJSON(),
222
- new SlashCommandBuilder()
223
- .setName('accept-always')
224
- .setDescription('Accept and auto-approve future requests matching this pattern')
225
- .toJSON(),
226
- new SlashCommandBuilder()
227
- .setName('reject')
228
- .setDescription('Reject a pending permission request')
223
+ .setName('abort')
224
+ .setDescription('Abort the current OpenCode request in this thread')
229
225
  .toJSON(),
230
226
  new SlashCommandBuilder()
231
- .setName('abort')
227
+ .setName('stop')
232
228
  .setDescription('Abort the current OpenCode request in this thread')
233
229
  .toJSON(),
234
230
  new SlashCommandBuilder()
@@ -243,6 +239,10 @@ async function registerCommands(token: string, appId: string) {
243
239
  .setName('model')
244
240
  .setDescription('Set the preferred model for this channel or session')
245
241
  .toJSON(),
242
+ new SlashCommandBuilder()
243
+ .setName('agent')
244
+ .setDescription('Set the preferred agent for this channel or session')
245
+ .toJSON(),
246
246
  new SlashCommandBuilder()
247
247
  .setName('queue')
248
248
  .setDescription('Queue a message to be sent after the current response finishes')
@@ -269,6 +269,30 @@ async function registerCommands(token: string, appId: string) {
269
269
  .toJSON(),
270
270
  ]
271
271
 
272
+ // Add user-defined commands with -cmd suffix
273
+ for (const cmd of userCommands) {
274
+ if (SKIP_USER_COMMANDS.includes(cmd.name)) {
275
+ continue
276
+ }
277
+
278
+ const commandName = `${cmd.name}-cmd`
279
+ const description = cmd.description || `Run /${cmd.name} command`
280
+
281
+ commands.push(
282
+ new SlashCommandBuilder()
283
+ .setName(commandName)
284
+ .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
285
+ .addStringOption((option) => {
286
+ option
287
+ .setName('arguments')
288
+ .setDescription('Arguments to pass to the command')
289
+ .setRequired(false)
290
+ return option
291
+ })
292
+ .toJSON(),
293
+ )
294
+ }
295
+
272
296
  const rest = new REST().setToken(token)
273
297
 
274
298
  try {
@@ -686,6 +710,7 @@ async function run({ restart, addChannels }: CliOptions) {
686
710
  guild: targetGuild,
687
711
  projectDirectory: project.worktree,
688
712
  appId,
713
+ botName: discordClient.user?.username,
689
714
  })
690
715
 
691
716
  createdChannels.push({
@@ -709,8 +734,37 @@ async function run({ restart, addChannels }: CliOptions) {
709
734
  }
710
735
  }
711
736
 
737
+ // Fetch user-defined commands using the already-running server
738
+ const allUserCommands: OpencodeCommand[] = []
739
+ try {
740
+ const commandsResponse = await getClient().command.list({
741
+ query: { directory: currentDir },
742
+ })
743
+ if (commandsResponse.data) {
744
+ allUserCommands.push(...commandsResponse.data)
745
+ }
746
+ } catch {
747
+ // Ignore errors fetching commands
748
+ }
749
+
750
+ // Log available user commands
751
+ const registrableCommands = allUserCommands.filter(
752
+ (cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
753
+ )
754
+
755
+ if (registrableCommands.length > 0) {
756
+ const commandList = registrableCommands
757
+ .map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
758
+ .join('\n')
759
+
760
+ note(
761
+ `Found ${registrableCommands.length} user-defined command(s):\n${commandList}`,
762
+ 'OpenCode Commands',
763
+ )
764
+ }
765
+
712
766
  cliLogger.log('Registering slash commands asynchronously...')
713
- void registerCommands(token, appId)
767
+ void registerCommands(token, appId, allUserCommands)
714
768
  .then(() => {
715
769
  cliLogger.log('Slash commands registered!')
716
770
  })
@@ -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
+ }