kimaki 0.4.24 → 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 (86) hide show
  1. package/bin.js +6 -1
  2. package/dist/acp-client.test.js +149 -0
  3. package/dist/ai-tool-to-genai.js +3 -0
  4. package/dist/channel-management.js +14 -9
  5. package/dist/cli.js +148 -17
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +54 -0
  22. package/dist/discord-bot.js +35 -32
  23. package/dist/discord-utils.js +81 -15
  24. package/dist/format-tables.js +3 -0
  25. package/dist/genai-worker-wrapper.js +3 -0
  26. package/dist/genai-worker.js +3 -0
  27. package/dist/genai.js +3 -0
  28. package/dist/interaction-handler.js +89 -695
  29. package/dist/logger.js +46 -5
  30. package/dist/markdown.js +107 -0
  31. package/dist/markdown.test.js +31 -1
  32. package/dist/message-formatting.js +113 -28
  33. package/dist/message-formatting.test.js +73 -0
  34. package/dist/opencode.js +73 -16
  35. package/dist/session-handler.js +176 -63
  36. package/dist/system-message.js +7 -38
  37. package/dist/tools.js +3 -0
  38. package/dist/utils.js +3 -0
  39. package/dist/voice-handler.js +21 -8
  40. package/dist/voice.js +31 -12
  41. package/dist/worker-types.js +3 -0
  42. package/dist/xml.js +3 -0
  43. package/package.json +3 -3
  44. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  45. package/src/__snapshots__/compact-session-context.md +47 -0
  46. package/src/ai-tool-to-genai.ts +4 -0
  47. package/src/channel-management.ts +24 -8
  48. package/src/cli.ts +163 -18
  49. package/src/commands/abort.ts +94 -0
  50. package/src/commands/add-project.ts +139 -0
  51. package/src/commands/agent.ts +201 -0
  52. package/src/commands/ask-question.ts +276 -0
  53. package/src/commands/create-new-project.ts +111 -0
  54. package/src/{fork.ts → commands/fork.ts} +40 -7
  55. package/src/{model-command.ts → commands/model.ts} +31 -9
  56. package/src/commands/permissions.ts +146 -0
  57. package/src/commands/queue.ts +181 -0
  58. package/src/commands/resume.ts +230 -0
  59. package/src/commands/session.ts +184 -0
  60. package/src/commands/share.ts +96 -0
  61. package/src/commands/types.ts +25 -0
  62. package/src/commands/undo-redo.ts +213 -0
  63. package/src/commands/user-command.ts +178 -0
  64. package/src/database.ts +65 -0
  65. package/src/discord-bot.ts +40 -33
  66. package/src/discord-utils.ts +88 -14
  67. package/src/format-tables.ts +4 -0
  68. package/src/genai-worker-wrapper.ts +4 -0
  69. package/src/genai-worker.ts +4 -0
  70. package/src/genai.ts +4 -0
  71. package/src/interaction-handler.ts +111 -924
  72. package/src/logger.ts +51 -10
  73. package/src/markdown.test.ts +45 -1
  74. package/src/markdown.ts +136 -0
  75. package/src/message-formatting.test.ts +81 -0
  76. package/src/message-formatting.ts +143 -30
  77. package/src/opencode.ts +84 -21
  78. package/src/session-handler.ts +248 -91
  79. package/src/system-message.ts +8 -38
  80. package/src/tools.ts +4 -0
  81. package/src/utils.ts +4 -0
  82. package/src/voice-handler.ts +24 -9
  83. package/src/voice.ts +36 -13
  84. package/src/worker-types.ts +4 -0
  85. package/src/xml.ts +4 -0
  86. package/README.md +0 -48
package/dist/opencode.js CHANGED
@@ -1,6 +1,11 @@
1
+ // OpenCode server process manager.
2
+ // Spawns and maintains OpenCode API servers per project directory,
3
+ // handles automatic restarts on failure, and provides typed SDK clients.
1
4
  import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
2
6
  import net from 'node:net';
3
7
  import { createOpencodeClient, } from '@opencode-ai/sdk';
8
+ import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
4
9
  import { createLogger } from './logger.js';
5
10
  const opencodeLogger = createLogger('OPENCODE');
6
11
  const opencodeServers = new Map();
@@ -27,22 +32,37 @@ async function waitForServer(port, maxAttempts = 30) {
27
32
  for (let i = 0; i < maxAttempts; i++) {
28
33
  try {
29
34
  const endpoints = [
30
- `http://localhost:${port}/api/health`,
31
- `http://localhost:${port}/`,
32
- `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`,
33
38
  ];
34
39
  for (const endpoint of endpoints) {
35
40
  try {
36
41
  const response = await fetch(endpoint);
37
42
  if (response.status < 500) {
38
- opencodeLogger.log(`Server ready on port `);
39
43
  return true;
40
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
+ }
41
56
  }
42
- catch (e) { }
43
57
  }
44
58
  }
45
- 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
+ }
46
66
  await new Promise((resolve) => setTimeout(resolve, 1000));
47
67
  }
48
68
  throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`);
@@ -59,8 +79,16 @@ export async function initializeOpencodeForDirectory(directory) {
59
79
  return entry.client;
60
80
  };
61
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
+ }
62
89
  const port = await getOpenPort();
63
- const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
90
+ const opencodeBinDir = `${process.env.HOME}/.opencode/bin`;
91
+ const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`;
64
92
  const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
65
93
  stdio: 'pipe',
66
94
  detached: false,
@@ -80,14 +108,17 @@ export async function initializeOpencodeForDirectory(directory) {
80
108
  OPENCODE_PORT: port.toString(),
81
109
  },
82
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})`);
83
114
  serverProcess.stdout?.on('data', (data) => {
84
- opencodeLogger.log(`opencode ${directory}: ${data.toString().trim()}`);
115
+ logBuffer.push(`[stdout] ${data.toString().trim()}`);
85
116
  });
86
117
  serverProcess.stderr?.on('data', (data) => {
87
- opencodeLogger.error(`opencode ${directory}: ${data.toString().trim()}`);
118
+ logBuffer.push(`[stderr] ${data.toString().trim()}`);
88
119
  });
89
120
  serverProcess.on('error', (error) => {
90
- opencodeLogger.error(`Failed to start server on port :`, port, error);
121
+ logBuffer.push(`Failed to start server on port ${port}: ${error}`);
91
122
  });
92
123
  serverProcess.on('exit', (code) => {
93
124
  opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
@@ -109,17 +140,35 @@ export async function initializeOpencodeForDirectory(directory) {
109
140
  serverRetryCount.delete(directory);
110
141
  }
111
142
  });
112
- 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
+ });
113
160
  const client = createOpencodeClient({
114
- baseUrl: `http://localhost:${port}`,
115
- fetch: (request) => fetch(request, {
116
- // @ts-ignore
117
- timeout: false,
118
- }),
161
+ baseUrl,
162
+ fetch: fetchWithTimeout,
163
+ });
164
+ const clientV2 = createOpencodeClientV2({
165
+ baseUrl,
166
+ fetch: fetchWithTimeout,
119
167
  });
120
168
  opencodeServers.set(directory, {
121
169
  process: serverProcess,
122
170
  client,
171
+ clientV2,
123
172
  port,
124
173
  });
125
174
  return () => {
@@ -133,3 +182,11 @@ export async function initializeOpencodeForDirectory(directory) {
133
182
  export function getOpencodeServers() {
134
183
  return opencodeServers;
135
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
+ }
@@ -1,30 +1,90 @@
1
+ // OpenCode session lifecycle manager.
2
+ // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
+ // Handles streaming events, permissions, abort signals, and message queuing.
1
4
  import prettyMilliseconds from 'pretty-ms';
2
- import { getDatabase, getSessionModel, getChannelModel } from './database.js';
3
- import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
4
- import { sendThreadMessage } from './discord-utils.js';
5
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
6
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
7
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js';
5
8
  import { formatPart } from './message-formatting.js';
6
9
  import { getOpencodeSystemMessage } from './system-message.js';
7
10
  import { createLogger } from './logger.js';
8
11
  import { isAbortError } from './utils.js';
12
+ import { showAskUserQuestionDropdowns } from './commands/ask-question.js';
9
13
  const sessionLogger = createLogger('SESSION');
10
14
  const voiceLogger = createLogger('VOICE');
11
15
  const discordLogger = createLogger('DISCORD');
12
- export function parseSlashCommand(text) {
13
- const trimmed = text.trim();
14
- if (!trimmed.startsWith('/')) {
15
- return { isCommand: false };
16
+ export const abortControllers = new Map();
17
+ export const pendingPermissions = new Map();
18
+ // Queue of messages waiting to be sent after current response finishes
19
+ // Key is threadId, value is array of queued messages
20
+ export const messageQueue = new Map();
21
+ export function addToQueue({ threadId, message, }) {
22
+ const queue = messageQueue.get(threadId) || [];
23
+ queue.push(message);
24
+ messageQueue.set(threadId, queue);
25
+ return queue.length;
26
+ }
27
+ export function getQueueLength(threadId) {
28
+ return messageQueue.get(threadId)?.length || 0;
29
+ }
30
+ export function clearQueue(threadId) {
31
+ messageQueue.delete(threadId);
32
+ }
33
+ /**
34
+ * Abort a running session and retry with the last user message.
35
+ * Used when model preference changes mid-request.
36
+ * Fetches last user message from OpenCode API instead of tracking in memory.
37
+ * @returns true if aborted and retry scheduled, false if no active request
38
+ */
39
+ export async function abortAndRetrySession({ sessionId, thread, projectDirectory, }) {
40
+ const controller = abortControllers.get(sessionId);
41
+ if (!controller) {
42
+ sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`);
43
+ return false;
16
44
  }
17
- const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/);
18
- if (!match) {
19
- return { isCommand: false };
45
+ sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
46
+ // Abort with special reason so we don't show "completed" message
47
+ controller.abort('model-change');
48
+ // Also call the API abort endpoint
49
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
50
+ try {
51
+ await getClient().session.abort({ path: { id: sessionId } });
52
+ }
53
+ catch (e) {
54
+ sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e);
20
55
  }
21
- const command = match[1];
22
- const args = match[2]?.trim() || '';
23
- return { isCommand: true, command, arguments: args };
56
+ // Small delay to let the abort propagate
57
+ await new Promise((resolve) => { setTimeout(resolve, 300); });
58
+ // Fetch last user message from API
59
+ sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`);
60
+ const messagesResponse = await getClient().session.messages({ path: { id: sessionId } });
61
+ const messages = messagesResponse.data || [];
62
+ const lastUserMessage = [...messages].reverse().find((m) => m.info.role === 'user');
63
+ if (!lastUserMessage) {
64
+ sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`);
65
+ return false;
66
+ }
67
+ // Extract text and images from parts
68
+ const textPart = lastUserMessage.parts.find((p) => p.type === 'text');
69
+ const prompt = textPart?.text || '';
70
+ const images = lastUserMessage.parts.filter((p) => p.type === 'file');
71
+ sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`);
72
+ // Use setImmediate to avoid blocking
73
+ setImmediate(() => {
74
+ handleOpencodeSession({
75
+ prompt,
76
+ thread,
77
+ projectDirectory,
78
+ images,
79
+ }).catch(async (e) => {
80
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e);
81
+ const errorMsg = e instanceof Error ? e.message : String(e);
82
+ await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`);
83
+ });
84
+ });
85
+ return true;
24
86
  }
25
- export const abortControllers = new Map();
26
- export const pendingPermissions = new Map();
27
- export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], parsedCommand, channelId, }) {
87
+ export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, }) {
28
88
  voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
29
89
  const sessionStartTime = Date.now();
30
90
  const directory = projectDirectory || process.cwd();
@@ -103,9 +163,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
103
163
  sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
104
164
  return;
105
165
  }
106
- const eventsResult = await getClient().event.subscribe({
107
- signal: abortController.signal,
108
- });
166
+ // Use v2 client for event subscription (has proper types for question.asked events)
167
+ const clientV2 = getOpencodeClientV2(directory);
168
+ if (!clientV2) {
169
+ throw new Error(`OpenCode v2 client not found for directory: ${directory}`);
170
+ }
171
+ const eventsResult = await clientV2.event.subscribe({ directory }, { signal: abortController.signal });
109
172
  if (abortController.signal.aborted) {
110
173
  sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
111
174
  return;
@@ -120,6 +183,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
120
183
  let stopTyping = null;
121
184
  let usedModel;
122
185
  let usedProviderID;
186
+ let usedAgent;
123
187
  let tokensUsedInSession = 0;
124
188
  let lastDisplayedContextPercentage = 0;
125
189
  let modelContextLimit;
@@ -159,7 +223,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
159
223
  const sendPartMessage = async (part) => {
160
224
  const content = formatPart(part) + '\n\n';
161
225
  if (!content.trim() || content.length === 0) {
162
- discordLogger.log(`SKIP: Part ${part.id} has no content`);
226
+ // discordLogger.log(`SKIP: Part ${part.id} has no content`)
163
227
  return;
164
228
  }
165
229
  if (sentPartIds.has(part.id)) {
@@ -193,6 +257,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
193
257
  assistantMessageId = msg.id;
194
258
  usedModel = msg.modelID;
195
259
  usedProviderID = msg.providerID;
260
+ usedAgent = msg.mode;
196
261
  if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
197
262
  if (!modelContextLimit) {
198
263
  try {
@@ -212,7 +277,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
212
277
  const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
213
278
  if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
214
279
  lastDisplayedContextPercentage = thresholdCrossed;
215
- await sendThreadMessage(thread, `◼︎ context usage ${currentPercentage}%`);
280
+ await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`);
216
281
  }
217
282
  }
218
283
  }
@@ -278,19 +343,16 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
278
343
  }
279
344
  break;
280
345
  }
281
- else if (event.type === 'permission.updated') {
346
+ else if (event.type === 'permission.asked') {
282
347
  const permission = event.properties;
283
348
  if (permission.sessionID !== session.id) {
284
349
  voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
285
350
  continue;
286
351
  }
287
- sessionLogger.log(`Permission requested: type=${permission.type}, title=${permission.title}`);
288
- const patternStr = Array.isArray(permission.pattern)
289
- ? permission.pattern.join(', ')
290
- : permission.pattern || '';
352
+ sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
353
+ const patternStr = permission.patterns.join(', ');
291
354
  const permissionMessage = await sendThreadMessage(thread, `⚠️ **Permission Required**\n\n` +
292
- `**Type:** \`${permission.type}\`\n` +
293
- `**Action:** ${permission.title}\n` +
355
+ `**Type:** \`${permission.permission}\`\n` +
294
356
  (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
295
357
  `\nUse \`/accept\` or \`/reject\` to respond.`);
296
358
  pendingPermissions.set(thread.id, {
@@ -300,16 +362,31 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
300
362
  });
301
363
  }
302
364
  else if (event.type === 'permission.replied') {
303
- const { permissionID, response, sessionID } = event.properties;
365
+ const { requestID, reply, sessionID } = event.properties;
304
366
  if (sessionID !== session.id) {
305
367
  continue;
306
368
  }
307
- sessionLogger.log(`Permission ${permissionID} replied with: ${response}`);
369
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
308
370
  const pending = pendingPermissions.get(thread.id);
309
- if (pending && pending.permission.id === permissionID) {
371
+ if (pending && pending.permission.id === requestID) {
310
372
  pendingPermissions.delete(thread.id);
311
373
  }
312
374
  }
375
+ else if (event.type === 'question.asked') {
376
+ const questionRequest = event.properties;
377
+ if (questionRequest.sessionID !== session.id) {
378
+ sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
379
+ continue;
380
+ }
381
+ sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
382
+ await showAskUserQuestionDropdowns({
383
+ thread,
384
+ sessionId: session.id,
385
+ directory,
386
+ requestId: questionRequest.id,
387
+ input: { questions: questionRequest.questions },
388
+ });
389
+ }
313
390
  }
314
391
  }
315
392
  catch (e) {
@@ -340,6 +417,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
340
417
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
341
418
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
342
419
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
420
+ const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : '';
343
421
  let contextInfo = '';
344
422
  try {
345
423
  const providersResponse = await getClient().provider.list({ query: { directory } });
@@ -353,8 +431,34 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
353
431
  catch (e) {
354
432
  sessionLogger.error('Failed to fetch provider info for context percentage:', e);
355
433
  }
356
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`);
434
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
357
435
  sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
436
+ // Process queued messages after completion
437
+ const queue = messageQueue.get(thread.id);
438
+ if (queue && queue.length > 0) {
439
+ const nextMessage = queue.shift();
440
+ if (queue.length === 0) {
441
+ messageQueue.delete(thread.id);
442
+ }
443
+ sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`);
444
+ // Show that queued message is being sent
445
+ await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
446
+ // Send the queued message as a new prompt (recursive call)
447
+ // Use setImmediate to avoid blocking and allow this finally to complete
448
+ setImmediate(() => {
449
+ handleOpencodeSession({
450
+ prompt: nextMessage.prompt,
451
+ thread,
452
+ projectDirectory,
453
+ images: nextMessage.images,
454
+ channelId,
455
+ }).catch(async (e) => {
456
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
457
+ const errorMsg = e instanceof Error ? e.message : String(e);
458
+ await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`);
459
+ });
460
+ });
461
+ }
358
462
  }
359
463
  else {
360
464
  sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
@@ -368,49 +472,58 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
368
472
  return;
369
473
  }
370
474
  stopTyping = startTyping();
371
- let response;
372
- if (parsedCommand?.isCommand) {
373
- sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
374
- response = await getClient().session.command({
475
+ voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
476
+ // append image paths to prompt so ai knows where they are on disk
477
+ const promptWithImagePaths = (() => {
478
+ if (images.length === 0) {
479
+ return prompt;
480
+ }
481
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
482
+ const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n');
483
+ return `${prompt}\n\n**attached images:**\n${imagePathsList}`;
484
+ })();
485
+ const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
486
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
487
+ // Get model preference: session-level overrides channel-level
488
+ const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
489
+ const modelParam = (() => {
490
+ if (!modelPreference) {
491
+ return undefined;
492
+ }
493
+ const [providerID, ...modelParts] = modelPreference.split('/');
494
+ const modelID = modelParts.join('/');
495
+ if (!providerID || !modelID) {
496
+ return undefined;
497
+ }
498
+ sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
499
+ return { providerID, modelID };
500
+ })();
501
+ // Get agent preference: session-level overrides channel-level
502
+ const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
503
+ if (agentPreference) {
504
+ sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
505
+ }
506
+ // Use session.command API for slash commands, session.prompt for regular messages
507
+ const response = command
508
+ ? await getClient().session.command({
375
509
  path: { id: session.id },
376
510
  body: {
377
- command: parsedCommand.command,
378
- arguments: parsedCommand.arguments,
511
+ command: command.name,
512
+ arguments: command.arguments,
513
+ agent: agentPreference,
379
514
  },
380
515
  signal: abortController.signal,
381
- });
382
- }
383
- else {
384
- voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
385
- if (images.length > 0) {
386
- sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
387
- }
388
- const parts = [{ type: 'text', text: prompt }, ...images];
389
- sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
390
- // Get model preference: session-level overrides channel-level
391
- const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
392
- const modelParam = (() => {
393
- if (!modelPreference) {
394
- return undefined;
395
- }
396
- const [providerID, ...modelParts] = modelPreference.split('/');
397
- const modelID = modelParts.join('/');
398
- if (!providerID || !modelID) {
399
- return undefined;
400
- }
401
- sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
402
- return { providerID, modelID };
403
- })();
404
- response = await getClient().session.prompt({
516
+ })
517
+ : await getClient().session.prompt({
405
518
  path: { id: session.id },
406
519
  body: {
407
520
  parts,
408
521
  system: getOpencodeSystemMessage({ sessionId: session.id }),
409
522
  model: modelParam,
523
+ agent: agentPreference,
410
524
  },
411
525
  signal: abortController.signal,
412
526
  });
413
- }
414
527
  if (response.error) {
415
528
  const errorMessage = (() => {
416
529
  const err = response.error;
@@ -1,3 +1,6 @@
1
+ // OpenCode system prompt generator.
2
+ // Creates the system message injected into every OpenCode session,
3
+ // including Discord-specific formatting rules, diff commands, and permissions info.
1
4
  export function getOpencodeSystemMessage({ sessionId }) {
2
5
  return `
3
6
  The user is reading your messages from inside Discord, via kimaki.xyz
@@ -14,24 +17,6 @@ Only users with these Discord permissions can send messages to the bot:
14
17
  - Manage Server permission
15
18
  - "Kimaki" role (case-insensitive)
16
19
 
17
- ## changing the model
18
-
19
- To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
20
-
21
- \`\`\`json
22
- {
23
- "model": "anthropic/claude-sonnet-4-20250514"
24
- }
25
- \`\`\`
26
-
27
- Examples:
28
- - \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
29
- - \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
30
- - \`"openai/gpt-4o"\` - GPT-4o
31
- - \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
32
-
33
- Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
34
-
35
20
  ## uploading files to discord
36
21
 
37
22
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
@@ -52,7 +37,9 @@ bunx critique web -- path/to/file1.ts path/to/file2.ts
52
37
 
53
38
  You can also show latest commit changes using:
54
39
 
55
- bunx critique web HEAD~1
40
+ bunx critique web HEAD
41
+
42
+ bunx critique web HEAD~1 to get the one before last
56
43
 
57
44
  Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
58
45
 
@@ -66,27 +53,9 @@ the max heading level is 3, so do not use ####
66
53
 
67
54
  headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
68
55
 
69
- ## tables
70
-
71
- discord does NOT support markdown gfm tables.
72
-
73
- so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
74
-
75
- \`\`\`
76
- Item Qty Price
77
- ---------- --- -----
78
- Apples 10 $5
79
- Oranges 3 $2
80
- \`\`\`
81
-
82
- Using code blocks will make the content use monospaced font so that space will be aligned correctly
83
-
84
- 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
85
-
86
- code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
87
56
 
88
57
  ## diagrams
89
58
 
90
- you can create diagrams wrapping them in code blocks too.
59
+ you can create diagrams wrapping them in code blocks.
91
60
  `;
92
61
  }
package/dist/tools.js CHANGED
@@ -1,3 +1,6 @@
1
+ // Voice assistant tool definitions for the GenAI worker.
2
+ // Provides tools for managing OpenCode sessions (create, submit, abort),
3
+ // listing chats, searching files, and reading session messages.
1
4
  import { tool } from 'ai';
2
5
  import { z } from 'zod';
3
6
  import { spawn } from 'node:child_process';
package/dist/utils.js CHANGED
@@ -1,3 +1,6 @@
1
+ // General utility functions for the bot.
2
+ // Includes Discord OAuth URL generation, array deduplication,
3
+ // abort error detection, and date/time formatting helpers.
1
4
  import { PermissionsBitField } from 'discord.js';
2
5
  export function generateBotInstallUrl({ clientId, permissions = [
3
6
  PermissionsBitField.Flags.ViewChannel,
@@ -1,3 +1,6 @@
1
+ // Discord voice channel connection and audio stream handler.
2
+ // Manages joining/leaving voice channels, captures user audio, resamples to 16kHz,
3
+ // and routes audio to the GenAI worker for real-time voice assistant interactions.
1
4
  import { VoiceConnectionStatus, EndBehaviorType, joinVoiceChannel, entersState, } from '@discordjs/voice';
2
5
  import { exec } from 'node:child_process';
3
6
  import fs, { createWriteStream } from 'node:fs';
@@ -311,7 +314,7 @@ export async function cleanupVoiceConnection(guildId) {
311
314
  voiceConnections.delete(guildId);
312
315
  }
313
316
  }
314
- export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId, sessionMessages, }) {
317
+ export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId, currentSessionContext, lastSessionContext, }) {
315
318
  const audioAttachment = Array.from(message.attachments.values()).find((attachment) => attachment.contentType?.startsWith('audio/'));
316
319
  if (!audioAttachment)
317
320
  return null;
@@ -347,13 +350,23 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
347
350
  geminiApiKey = apiKeys.gemini_api_key;
348
351
  }
349
352
  }
350
- const transcription = await transcribeAudio({
351
- audio: audioBuffer,
352
- prompt: transcriptionPrompt,
353
- geminiApiKey,
354
- directory: projectDirectory,
355
- sessionMessages,
356
- });
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
+ }
357
370
  voiceLogger.log(`Transcription successful: "${transcription.slice(0, 50)}${transcription.length > 50 ? '...' : ''}"`);
358
371
  if (isNewThread) {
359
372
  const threadName = transcription.replace(/\s+/g, ' ').trim().slice(0, 80);