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/opencode.js CHANGED
@@ -2,8 +2,10 @@
2
2
  // Spawns and maintains OpenCode API servers per project directory,
3
3
  // handles automatic restarts on failure, and provides typed SDK clients.
4
4
  import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
5
6
  import net from 'node:net';
6
7
  import { createOpencodeClient, } from '@opencode-ai/sdk';
8
+ import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
7
9
  import { createLogger } from './logger.js';
8
10
  const opencodeLogger = createLogger('OPENCODE');
9
11
  const opencodeServers = new Map();
@@ -30,22 +32,37 @@ async function waitForServer(port, maxAttempts = 30) {
30
32
  for (let i = 0; i < maxAttempts; i++) {
31
33
  try {
32
34
  const endpoints = [
33
- `http://localhost:${port}/api/health`,
34
- `http://localhost:${port}/`,
35
- `http://localhost:${port}/api`,
35
+ `http://127.0.0.1:${port}/api/health`,
36
+ `http://127.0.0.1:${port}/`,
37
+ `http://127.0.0.1:${port}/api`,
36
38
  ];
37
39
  for (const endpoint of endpoints) {
38
40
  try {
39
41
  const response = await fetch(endpoint);
40
42
  if (response.status < 500) {
41
- opencodeLogger.log(`Server ready on port `);
42
43
  return true;
43
44
  }
45
+ const body = await response.text();
46
+ // Fatal errors that won't resolve with retrying
47
+ if (body.includes('BunInstallFailedError')) {
48
+ throw new Error(`Server failed to start: ${body.slice(0, 200)}`);
49
+ }
50
+ }
51
+ catch (e) {
52
+ // Re-throw fatal errors
53
+ if (e.message?.includes('Server failed to start')) {
54
+ throw e;
55
+ }
44
56
  }
45
- catch (e) { }
46
57
  }
47
58
  }
48
- catch (e) { }
59
+ catch (e) {
60
+ // Re-throw fatal errors that won't resolve with retrying
61
+ if (e.message?.includes('Server failed to start')) {
62
+ throw e;
63
+ }
64
+ opencodeLogger.debug(`Server polling attempt failed: ${e.message}`);
65
+ }
49
66
  await new Promise((resolve) => setTimeout(resolve, 1000));
50
67
  }
51
68
  throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`);
@@ -62,8 +79,16 @@ export async function initializeOpencodeForDirectory(directory) {
62
79
  return entry.client;
63
80
  };
64
81
  }
82
+ // Verify directory exists and is accessible before spawning
83
+ try {
84
+ fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK);
85
+ }
86
+ catch {
87
+ throw new Error(`Directory does not exist or is not accessible: ${directory}`);
88
+ }
65
89
  const port = await getOpenPort();
66
- const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
90
+ const opencodeBinDir = `${process.env.HOME}/.opencode/bin`;
91
+ const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`;
67
92
  const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
68
93
  stdio: 'pipe',
69
94
  detached: false,
@@ -83,14 +108,17 @@ export async function initializeOpencodeForDirectory(directory) {
83
108
  OPENCODE_PORT: port.toString(),
84
109
  },
85
110
  });
111
+ // Buffer logs until we know if server started successfully
112
+ const logBuffer = [];
113
+ logBuffer.push(`Spawned opencode serve --port ${port} in ${directory} (pid: ${serverProcess.pid})`);
86
114
  serverProcess.stdout?.on('data', (data) => {
87
- opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`);
115
+ logBuffer.push(`[stdout] ${data.toString().trim()}`);
88
116
  });
89
117
  serverProcess.stderr?.on('data', (data) => {
90
- opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`);
118
+ logBuffer.push(`[stderr] ${data.toString().trim()}`);
91
119
  });
92
120
  serverProcess.on('error', (error) => {
93
- opencodeLogger.error(`Failed to start server on port :`, port, error);
121
+ logBuffer.push(`Failed to start server on port ${port}: ${error}`);
94
122
  });
95
123
  serverProcess.on('exit', (code) => {
96
124
  opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
@@ -112,17 +140,35 @@ export async function initializeOpencodeForDirectory(directory) {
112
140
  serverRetryCount.delete(directory);
113
141
  }
114
142
  });
115
- await waitForServer(port);
143
+ try {
144
+ await waitForServer(port);
145
+ opencodeLogger.log(`Server ready on port ${port}`);
146
+ }
147
+ catch (e) {
148
+ // Dump buffered logs on failure
149
+ opencodeLogger.error(`Server failed to start for ${directory}:`);
150
+ for (const line of logBuffer) {
151
+ opencodeLogger.error(` ${line}`);
152
+ }
153
+ throw e;
154
+ }
155
+ const baseUrl = `http://127.0.0.1:${port}`;
156
+ const fetchWithTimeout = (request) => fetch(request, {
157
+ // @ts-ignore
158
+ timeout: false,
159
+ });
116
160
  const client = createOpencodeClient({
117
- baseUrl: `http://localhost:${port}`,
118
- fetch: (request) => fetch(request, {
119
- // @ts-ignore
120
- timeout: false,
121
- }),
161
+ baseUrl,
162
+ fetch: fetchWithTimeout,
163
+ });
164
+ const clientV2 = createOpencodeClientV2({
165
+ baseUrl,
166
+ fetch: fetchWithTimeout,
122
167
  });
123
168
  opencodeServers.set(directory, {
124
169
  process: serverProcess,
125
170
  client,
171
+ clientV2,
126
172
  port,
127
173
  });
128
174
  return () => {
@@ -136,3 +182,11 @@ export async function initializeOpencodeForDirectory(directory) {
136
182
  export function getOpencodeServers() {
137
183
  return opencodeServers;
138
184
  }
185
+ export function getOpencodeServerPort(directory) {
186
+ const entry = opencodeServers.get(directory);
187
+ return entry?.port ?? null;
188
+ }
189
+ export function getOpencodeClientV2(directory) {
190
+ const entry = opencodeServers.get(directory);
191
+ return entry?.clientV2 ?? null;
192
+ }
@@ -2,29 +2,18 @@
2
2
  // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
3
  // Handles streaming events, permissions, abort signals, and message queuing.
4
4
  import prettyMilliseconds from 'pretty-ms';
5
- import { getDatabase, getSessionModel, getChannelModel } from './database.js';
6
- import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
5
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
6
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
7
7
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js';
8
8
  import { formatPart } from './message-formatting.js';
9
9
  import { getOpencodeSystemMessage } from './system-message.js';
10
10
  import { createLogger } from './logger.js';
11
11
  import { isAbortError } from './utils.js';
12
+ import { showAskUserQuestionDropdowns } from './commands/ask-question.js';
13
+ import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
12
14
  const sessionLogger = createLogger('SESSION');
13
15
  const voiceLogger = createLogger('VOICE');
14
16
  const discordLogger = createLogger('DISCORD');
15
- export function parseSlashCommand(text) {
16
- const trimmed = text.trim();
17
- if (!trimmed.startsWith('/')) {
18
- return { isCommand: false };
19
- }
20
- const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/);
21
- if (!match) {
22
- return { isCommand: false };
23
- }
24
- const command = match[1];
25
- const args = match[2]?.trim() || '';
26
- return { isCommand: true, command, arguments: args };
27
- }
28
17
  export const abortControllers = new Map();
29
18
  export const pendingPermissions = new Map();
30
19
  // Queue of messages waiting to be sent after current response finishes
@@ -42,7 +31,61 @@ export function getQueueLength(threadId) {
42
31
  export function clearQueue(threadId) {
43
32
  messageQueue.delete(threadId);
44
33
  }
45
- export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], parsedCommand, channelId, }) {
34
+ /**
35
+ * Abort a running session and retry with the last user message.
36
+ * Used when model preference changes mid-request.
37
+ * Fetches last user message from OpenCode API instead of tracking in memory.
38
+ * @returns true if aborted and retry scheduled, false if no active request
39
+ */
40
+ export async function abortAndRetrySession({ sessionId, thread, projectDirectory, }) {
41
+ const controller = abortControllers.get(sessionId);
42
+ if (!controller) {
43
+ sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`);
44
+ return false;
45
+ }
46
+ sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
47
+ // Abort with special reason so we don't show "completed" message
48
+ controller.abort('model-change');
49
+ // Also call the API abort endpoint
50
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
51
+ try {
52
+ await getClient().session.abort({ path: { id: sessionId } });
53
+ }
54
+ catch (e) {
55
+ sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e);
56
+ }
57
+ // Small delay to let the abort propagate
58
+ await new Promise((resolve) => { setTimeout(resolve, 300); });
59
+ // Fetch last user message from API
60
+ sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`);
61
+ const messagesResponse = await getClient().session.messages({ path: { id: sessionId } });
62
+ const messages = messagesResponse.data || [];
63
+ const lastUserMessage = [...messages].reverse().find((m) => m.info.role === 'user');
64
+ if (!lastUserMessage) {
65
+ sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`);
66
+ return false;
67
+ }
68
+ // Extract text and images from parts
69
+ const textPart = lastUserMessage.parts.find((p) => p.type === 'text');
70
+ const prompt = textPart?.text || '';
71
+ const images = lastUserMessage.parts.filter((p) => p.type === 'file');
72
+ sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`);
73
+ // Use setImmediate to avoid blocking
74
+ setImmediate(() => {
75
+ handleOpencodeSession({
76
+ prompt,
77
+ thread,
78
+ projectDirectory,
79
+ images,
80
+ }).catch(async (e) => {
81
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e);
82
+ const errorMsg = e instanceof Error ? e.message : String(e);
83
+ await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`);
84
+ });
85
+ });
86
+ return true;
87
+ }
88
+ export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, }) {
46
89
  voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
47
90
  const sessionStartTime = Date.now();
48
91
  const directory = projectDirectory || process.cwd();
@@ -100,11 +143,14 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
100
143
  },
101
144
  body: { response: 'reject' },
102
145
  });
146
+ // Clean up both the pending permission and its dropdown context
147
+ cleanupPermissionContext(pendingPerm.contextHash);
103
148
  pendingPermissions.delete(thread.id);
104
149
  await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`);
105
150
  }
106
151
  catch (e) {
107
152
  sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e);
153
+ cleanupPermissionContext(pendingPerm.contextHash);
108
154
  pendingPermissions.delete(thread.id);
109
155
  }
110
156
  }
@@ -121,9 +167,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
121
167
  sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
122
168
  return;
123
169
  }
124
- const eventsResult = await getClient().event.subscribe({
125
- signal: abortController.signal,
126
- });
170
+ // Use v2 client for event subscription (has proper types for question.asked events)
171
+ const clientV2 = getOpencodeClientV2(directory);
172
+ if (!clientV2) {
173
+ throw new Error(`OpenCode v2 client not found for directory: ${directory}`);
174
+ }
175
+ const eventsResult = await clientV2.event.subscribe({ directory }, { signal: abortController.signal });
127
176
  if (abortController.signal.aborted) {
128
177
  sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
129
178
  return;
@@ -138,6 +187,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
138
187
  let stopTyping = null;
139
188
  let usedModel;
140
189
  let usedProviderID;
190
+ let usedAgent;
141
191
  let tokensUsedInSession = 0;
142
192
  let lastDisplayedContextPercentage = 0;
143
193
  let modelContextLimit;
@@ -177,7 +227,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
177
227
  const sendPartMessage = async (part) => {
178
228
  const content = formatPart(part) + '\n\n';
179
229
  if (!content.trim() || content.length === 0) {
180
- discordLogger.log(`SKIP: Part ${part.id} has no content`);
230
+ // discordLogger.log(`SKIP: Part ${part.id} has no content`)
181
231
  return;
182
232
  }
183
233
  if (sentPartIds.has(part.id)) {
@@ -211,6 +261,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
211
261
  assistantMessageId = msg.id;
212
262
  usedModel = msg.modelID;
213
263
  usedProviderID = msg.providerID;
264
+ usedAgent = msg.mode;
214
265
  if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
215
266
  if (!modelContextLimit) {
216
267
  try {
@@ -296,38 +347,53 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
296
347
  }
297
348
  break;
298
349
  }
299
- else if (event.type === 'permission.updated') {
350
+ else if (event.type === 'permission.asked') {
300
351
  const permission = event.properties;
301
352
  if (permission.sessionID !== session.id) {
302
353
  voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
303
354
  continue;
304
355
  }
305
- sessionLogger.log(`Permission requested: type=${permission.type}, title=${permission.title}`);
306
- const patternStr = Array.isArray(permission.pattern)
307
- ? permission.pattern.join(', ')
308
- : permission.pattern || '';
309
- const permissionMessage = await sendThreadMessage(thread, `⚠️ **Permission Required**\n\n` +
310
- `**Type:** \`${permission.type}\`\n` +
311
- `**Action:** ${permission.title}\n` +
312
- (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
313
- `\nUse \`/accept\` or \`/reject\` to respond.`);
356
+ sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
357
+ // Show dropdown instead of text message
358
+ const { messageId, contextHash } = await showPermissionDropdown({
359
+ thread,
360
+ permission,
361
+ directory,
362
+ });
314
363
  pendingPermissions.set(thread.id, {
315
364
  permission,
316
- messageId: permissionMessage.id,
365
+ messageId,
317
366
  directory,
367
+ contextHash,
318
368
  });
319
369
  }
320
370
  else if (event.type === 'permission.replied') {
321
- const { permissionID, response, sessionID } = event.properties;
371
+ const { requestID, reply, sessionID } = event.properties;
322
372
  if (sessionID !== session.id) {
323
373
  continue;
324
374
  }
325
- sessionLogger.log(`Permission ${permissionID} replied with: ${response}`);
375
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
326
376
  const pending = pendingPermissions.get(thread.id);
327
- if (pending && pending.permission.id === permissionID) {
377
+ if (pending && pending.permission.id === requestID) {
378
+ cleanupPermissionContext(pending.contextHash);
328
379
  pendingPermissions.delete(thread.id);
329
380
  }
330
381
  }
382
+ else if (event.type === 'question.asked') {
383
+ const questionRequest = event.properties;
384
+ if (questionRequest.sessionID !== session.id) {
385
+ sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
386
+ continue;
387
+ }
388
+ sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
389
+ await showAskUserQuestionDropdowns({
390
+ thread,
391
+ sessionId: session.id,
392
+ directory,
393
+ requestId: questionRequest.id,
394
+ input: { questions: questionRequest.questions },
395
+ });
396
+ }
331
397
  }
332
398
  }
333
399
  catch (e) {
@@ -358,6 +424,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
358
424
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
359
425
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
360
426
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
427
+ const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : '';
361
428
  let contextInfo = '';
362
429
  try {
363
430
  const providersResponse = await getClient().provider.list({ query: { directory } });
@@ -371,7 +438,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
371
438
  catch (e) {
372
439
  sessionLogger.error('Failed to fetch provider info for context percentage:', e);
373
440
  }
374
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
441
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
375
442
  sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
376
443
  // Process queued messages after completion
377
444
  const queue = messageQueue.get(thread.id);
@@ -412,49 +479,58 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
412
479
  return;
413
480
  }
414
481
  stopTyping = startTyping();
415
- let response;
416
- if (parsedCommand?.isCommand) {
417
- sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
418
- response = await getClient().session.command({
482
+ voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
483
+ // append image paths to prompt so ai knows where they are on disk
484
+ const promptWithImagePaths = (() => {
485
+ if (images.length === 0) {
486
+ return prompt;
487
+ }
488
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
489
+ const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n');
490
+ return `${prompt}\n\n**attached images:**\n${imagePathsList}`;
491
+ })();
492
+ const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
493
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
494
+ // Get model preference: session-level overrides channel-level
495
+ const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
496
+ const modelParam = (() => {
497
+ if (!modelPreference) {
498
+ return undefined;
499
+ }
500
+ const [providerID, ...modelParts] = modelPreference.split('/');
501
+ const modelID = modelParts.join('/');
502
+ if (!providerID || !modelID) {
503
+ return undefined;
504
+ }
505
+ sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
506
+ return { providerID, modelID };
507
+ })();
508
+ // Get agent preference: session-level overrides channel-level
509
+ const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
510
+ if (agentPreference) {
511
+ sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
512
+ }
513
+ // Use session.command API for slash commands, session.prompt for regular messages
514
+ const response = command
515
+ ? await getClient().session.command({
419
516
  path: { id: session.id },
420
517
  body: {
421
- command: parsedCommand.command,
422
- arguments: parsedCommand.arguments,
518
+ command: command.name,
519
+ arguments: command.arguments,
520
+ agent: agentPreference,
423
521
  },
424
522
  signal: abortController.signal,
425
- });
426
- }
427
- else {
428
- voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
429
- if (images.length > 0) {
430
- sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
431
- }
432
- const parts = [{ type: 'text', text: prompt }, ...images];
433
- sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
434
- // Get model preference: session-level overrides channel-level
435
- const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
436
- const modelParam = (() => {
437
- if (!modelPreference) {
438
- return undefined;
439
- }
440
- const [providerID, ...modelParts] = modelPreference.split('/');
441
- const modelID = modelParts.join('/');
442
- if (!providerID || !modelID) {
443
- return undefined;
444
- }
445
- sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
446
- return { providerID, modelID };
447
- })();
448
- response = await getClient().session.prompt({
523
+ })
524
+ : await getClient().session.prompt({
449
525
  path: { id: session.id },
450
526
  body: {
451
527
  parts,
452
528
  system: getOpencodeSystemMessage({ sessionId: session.id }),
453
529
  model: modelParam,
530
+ agent: agentPreference,
454
531
  },
455
532
  signal: abortController.signal,
456
533
  });
457
- }
458
534
  if (response.error) {
459
535
  const errorMessage = (() => {
460
536
  const err = response.error;
@@ -17,24 +17,6 @@ Only users with these Discord permissions can send messages to the bot:
17
17
  - Manage Server permission
18
18
  - "Kimaki" role (case-insensitive)
19
19
 
20
- ## changing the model
21
-
22
- To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
23
-
24
- \`\`\`json
25
- {
26
- "model": "anthropic/claude-sonnet-4-20250514"
27
- }
28
- \`\`\`
29
-
30
- Examples:
31
- - \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
32
- - \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
33
- - \`"openai/gpt-4o"\` - GPT-4o
34
- - \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
35
-
36
- Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
37
-
38
20
  ## uploading files to discord
39
21
 
40
22
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
@@ -55,7 +37,9 @@ bunx critique web -- path/to/file1.ts path/to/file2.ts
55
37
 
56
38
  You can also show latest commit changes using:
57
39
 
58
- bunx critique web HEAD~1
40
+ bunx critique web HEAD
41
+
42
+ bunx critique web HEAD~1 to get the one before last
59
43
 
60
44
  Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
61
45
 
@@ -69,40 +53,9 @@ the max heading level is 3, so do not use ####
69
53
 
70
54
  headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
71
55
 
72
- ## capitalization
73
-
74
- write casually like a discord user. never capitalize the initials of phrases or acronyms in your messages. use all lowercase instead.
75
-
76
- examples:
77
- - write "api" not "API"
78
- - write "url" not "URL"
79
- - write "json" not "JSON"
80
- - write "cli" not "CLI"
81
- - write "sdk" not "SDK"
82
-
83
- this makes your messages blend in naturally with how people actually type on discord.
84
-
85
- ## tables
86
-
87
- discord does NOT support markdown gfm tables.
88
-
89
- so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
90
-
91
- \`\`\`
92
- Item Qty Price
93
- ---------- --- -----
94
- Apples 10 $5
95
- Oranges 3 $2
96
- \`\`\`
97
-
98
- Using code blocks will make the content use monospaced font so that space will be aligned correctly
99
-
100
- IMPORTANT: add enough space characters to align the table! otherwise the content will not look good and will be difficult to understand for the user
101
-
102
- code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
103
56
 
104
57
  ## diagrams
105
58
 
106
- you can create diagrams wrapping them in code blocks too.
59
+ you can create diagrams wrapping them in code blocks.
107
60
  `;
108
61
  }
@@ -314,7 +314,7 @@ export async function cleanupVoiceConnection(guildId) {
314
314
  voiceConnections.delete(guildId);
315
315
  }
316
316
  }
317
- export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId, sessionMessages, }) {
317
+ export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId, currentSessionContext, lastSessionContext, }) {
318
318
  const audioAttachment = Array.from(message.attachments.values()).find((attachment) => attachment.contentType?.startsWith('audio/'));
319
319
  if (!audioAttachment)
320
320
  return null;
@@ -350,13 +350,23 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
350
350
  geminiApiKey = apiKeys.gemini_api_key;
351
351
  }
352
352
  }
353
- const transcription = await transcribeAudio({
354
- audio: audioBuffer,
355
- prompt: transcriptionPrompt,
356
- geminiApiKey,
357
- directory: projectDirectory,
358
- sessionMessages,
359
- });
353
+ let transcription;
354
+ try {
355
+ transcription = await transcribeAudio({
356
+ audio: audioBuffer,
357
+ prompt: transcriptionPrompt,
358
+ geminiApiKey,
359
+ directory: projectDirectory,
360
+ currentSessionContext,
361
+ lastSessionContext,
362
+ });
363
+ }
364
+ catch (error) {
365
+ const errMsg = error instanceof Error ? error.message : String(error);
366
+ voiceLogger.error(`Transcription failed:`, error);
367
+ await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`);
368
+ return null;
369
+ }
360
370
  voiceLogger.log(`Transcription successful: "${transcription.slice(0, 50)}${transcription.length > 50 ? '...' : ''}"`);
361
371
  if (isNewThread) {
362
372
  const threadName = transcription.replace(/\s+/g, ' ').trim().slice(0, 80);