kimaki 0.4.34 → 0.4.36

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 (79) hide show
  1. package/dist/ai-tool-to-genai.js +1 -3
  2. package/dist/channel-management.js +1 -1
  3. package/dist/cli.js +142 -39
  4. package/dist/commands/abort.js +1 -1
  5. package/dist/commands/add-project.js +1 -1
  6. package/dist/commands/agent.js +6 -2
  7. package/dist/commands/ask-question.js +2 -1
  8. package/dist/commands/fork.js +7 -7
  9. package/dist/commands/queue.js +2 -2
  10. package/dist/commands/remove-project.js +109 -0
  11. package/dist/commands/resume.js +3 -5
  12. package/dist/commands/session.js +56 -1
  13. package/dist/commands/share.js +1 -1
  14. package/dist/commands/undo-redo.js +2 -2
  15. package/dist/commands/user-command.js +3 -6
  16. package/dist/config.js +1 -1
  17. package/dist/discord-bot.js +4 -10
  18. package/dist/discord-utils.js +33 -9
  19. package/dist/genai.js +4 -6
  20. package/dist/interaction-handler.js +8 -1
  21. package/dist/markdown.js +1 -3
  22. package/dist/message-formatting.js +7 -3
  23. package/dist/openai-realtime.js +3 -5
  24. package/dist/opencode.js +2 -3
  25. package/dist/session-handler.js +42 -25
  26. package/dist/system-message.js +5 -3
  27. package/dist/tools.js +9 -22
  28. package/dist/unnest-code-blocks.js +4 -2
  29. package/dist/unnest-code-blocks.test.js +40 -15
  30. package/dist/voice-handler.js +9 -12
  31. package/dist/voice.js +5 -3
  32. package/dist/xml.js +2 -4
  33. package/package.json +3 -2
  34. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  35. package/src/__snapshots__/compact-session-context.md +31 -31
  36. package/src/ai-tool-to-genai.ts +3 -11
  37. package/src/channel-management.ts +14 -25
  38. package/src/cli.ts +290 -195
  39. package/src/commands/abort.ts +1 -3
  40. package/src/commands/add-project.ts +8 -14
  41. package/src/commands/agent.ts +16 -9
  42. package/src/commands/ask-question.ts +8 -7
  43. package/src/commands/create-new-project.ts +8 -14
  44. package/src/commands/fork.ts +23 -27
  45. package/src/commands/model.ts +14 -11
  46. package/src/commands/permissions.ts +1 -1
  47. package/src/commands/queue.ts +6 -19
  48. package/src/commands/remove-project.ts +136 -0
  49. package/src/commands/resume.ts +11 -30
  50. package/src/commands/session.ts +68 -9
  51. package/src/commands/share.ts +1 -3
  52. package/src/commands/types.ts +1 -3
  53. package/src/commands/undo-redo.ts +6 -18
  54. package/src/commands/user-command.ts +8 -10
  55. package/src/config.ts +5 -5
  56. package/src/database.ts +10 -8
  57. package/src/discord-bot.ts +22 -46
  58. package/src/discord-utils.ts +35 -18
  59. package/src/escape-backticks.test.ts +0 -2
  60. package/src/format-tables.ts +1 -4
  61. package/src/genai-worker-wrapper.ts +3 -9
  62. package/src/genai-worker.ts +4 -19
  63. package/src/genai.ts +10 -42
  64. package/src/interaction-handler.ts +133 -121
  65. package/src/markdown.test.ts +10 -32
  66. package/src/markdown.ts +6 -14
  67. package/src/message-formatting.ts +13 -14
  68. package/src/openai-realtime.ts +25 -47
  69. package/src/opencode.ts +26 -37
  70. package/src/session-handler.ts +111 -75
  71. package/src/system-message.ts +13 -3
  72. package/src/tools.ts +13 -39
  73. package/src/unnest-code-blocks.test.ts +42 -15
  74. package/src/unnest-code-blocks.ts +4 -2
  75. package/src/utils.ts +1 -4
  76. package/src/voice-handler.ts +34 -78
  77. package/src/voice.ts +11 -19
  78. package/src/xml.test.ts +1 -1
  79. package/src/xml.ts +3 -12
@@ -9,10 +9,11 @@ import { extractTagsArrays } from '../xml.js';
9
9
  import { handleOpencodeSession } from '../session-handler.js';
10
10
  import { createLogger } from '../logger.js';
11
11
  const logger = createLogger('SESSION');
12
- export async function handleSessionCommand({ command, appId, }) {
12
+ export async function handleSessionCommand({ command, appId }) {
13
13
  await command.deferReply({ ephemeral: false });
14
14
  const prompt = command.options.getString('prompt', true);
15
15
  const filesString = command.options.getString('files') || '';
16
+ const agent = command.options.getString('agent') || undefined;
16
17
  const channel = command.channel;
17
18
  if (!channel || channel.type !== ChannelType.GuildText) {
18
19
  await command.editReply('This command can only be used in text channels');
@@ -66,6 +67,7 @@ export async function handleSessionCommand({ command, appId, }) {
66
67
  thread,
67
68
  projectDirectory,
68
69
  channelId: textChannel.id,
70
+ agent,
69
71
  });
70
72
  }
71
73
  catch (error) {
@@ -73,8 +75,61 @@ export async function handleSessionCommand({ command, appId, }) {
73
75
  await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
74
76
  }
75
77
  }
78
+ async function handleAgentAutocomplete({ interaction, appId }) {
79
+ const focusedValue = interaction.options.getFocused();
80
+ let projectDirectory;
81
+ if (interaction.channel) {
82
+ const channel = interaction.channel;
83
+ if (channel.type === ChannelType.GuildText) {
84
+ const textChannel = channel;
85
+ if (textChannel.topic) {
86
+ const extracted = extractTagsArrays({
87
+ xml: textChannel.topic,
88
+ tags: ['kimaki.directory', 'kimaki.app'],
89
+ });
90
+ const channelAppId = extracted['kimaki.app']?.[0]?.trim();
91
+ if (channelAppId && channelAppId !== appId) {
92
+ await interaction.respond([]);
93
+ return;
94
+ }
95
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
96
+ }
97
+ }
98
+ }
99
+ if (!projectDirectory) {
100
+ await interaction.respond([]);
101
+ return;
102
+ }
103
+ try {
104
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
105
+ const agentsResponse = await getClient().app.agents({
106
+ query: { directory: projectDirectory },
107
+ });
108
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
109
+ await interaction.respond([]);
110
+ return;
111
+ }
112
+ const agents = agentsResponse.data
113
+ .filter((a) => a.mode === 'primary' || a.mode === 'all')
114
+ .filter((a) => a.name.toLowerCase().includes(focusedValue.toLowerCase()))
115
+ .slice(0, 25);
116
+ const choices = agents.map((agent) => ({
117
+ name: agent.name.slice(0, 100),
118
+ value: agent.name,
119
+ }));
120
+ await interaction.respond(choices);
121
+ }
122
+ catch (error) {
123
+ logger.error('[AUTOCOMPLETE] Error fetching agents:', error);
124
+ await interaction.respond([]);
125
+ }
126
+ }
76
127
  export async function handleSessionAutocomplete({ interaction, appId, }) {
77
128
  const focusedOption = interaction.options.getFocused(true);
129
+ if (focusedOption.name === 'agent') {
130
+ await handleAgentAutocomplete({ interaction, appId });
131
+ return;
132
+ }
78
133
  if (focusedOption.name !== 'files') {
79
134
  return;
80
135
  }
@@ -5,7 +5,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger } from '../logger.js';
7
7
  const logger = createLogger('SHARE');
8
- export async function handleShareCommand({ command, }) {
8
+ export async function handleShareCommand({ command }) {
9
9
  const channel = command.channel;
10
10
  if (!channel) {
11
11
  await command.reply({
@@ -5,7 +5,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger } from '../logger.js';
7
7
  const logger = createLogger('UNDO-REDO');
8
- export async function handleUndoCommand({ command, }) {
8
+ export async function handleUndoCommand({ command }) {
9
9
  const channel = command.channel;
10
10
  if (!channel) {
11
11
  await command.reply({
@@ -88,7 +88,7 @@ export async function handleUndoCommand({ command, }) {
88
88
  await command.editReply(`Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}`);
89
89
  }
90
90
  }
91
- export async function handleRedoCommand({ command, }) {
91
+ export async function handleRedoCommand({ command }) {
92
92
  const channel = command.channel;
93
93
  if (!channel) {
94
94
  await command.reply({
@@ -8,7 +8,7 @@ import { createLogger } from '../logger.js';
8
8
  import { getDatabase } from '../database.js';
9
9
  import fs from 'node:fs';
10
10
  const userCommandLogger = createLogger('USER_CMD');
11
- export const handleUserCommand = async ({ command, appId, }) => {
11
+ export const handleUserCommand = async ({ command, appId }) => {
12
12
  const discordCommandName = command.commandName;
13
13
  // Strip the -cmd suffix to get the actual OpenCode command name
14
14
  const commandName = discordCommandName.replace(/-cmd$/, '');
@@ -16,11 +16,8 @@ export const handleUserCommand = async ({ command, appId, }) => {
16
16
  userCommandLogger.log(`Executing /${commandName} (from /${discordCommandName}) with args: ${args}`);
17
17
  const channel = command.channel;
18
18
  userCommandLogger.log(`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`);
19
- const isThread = channel && [
20
- ChannelType.PublicThread,
21
- ChannelType.PrivateThread,
22
- ChannelType.AnnouncementThread,
23
- ].includes(channel.type);
19
+ const isThread = channel &&
20
+ [ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.AnnouncementThread].includes(channel.type);
24
21
  const isTextChannel = channel?.type === ChannelType.GuildText;
25
22
  if (!channel || (!isTextChannel && !isThread)) {
26
23
  await command.reply({
package/dist/config.js CHANGED
@@ -51,7 +51,7 @@ export function getLockPort() {
51
51
  let hash = 0;
52
52
  for (let i = 0; i < dir.length; i++) {
53
53
  const char = dir.charCodeAt(i);
54
- hash = ((hash << 5) - hash) + char;
54
+ hash = (hash << 5) - hash + char;
55
55
  hash = hash & hash; // Convert to 32bit integer
56
56
  }
57
57
  // Map to port range 30000-39999
@@ -8,14 +8,14 @@ import { getOpencodeSystemMessage } from './system-message.js';
8
8
  import { getFileAttachments, getTextAttachments } from './message-formatting.js';
9
9
  import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
10
10
  import { voiceConnections, cleanupVoiceConnection, processVoiceAttachment, registerVoiceStateHandler, } from './voice-handler.js';
11
- import { getCompactSessionContext, getLastSessionId, } from './markdown.js';
11
+ import { getCompactSessionContext, getLastSessionId } from './markdown.js';
12
12
  import { handleOpencodeSession } from './session-handler.js';
13
13
  import { registerInteractionHandler } from './interaction-handler.js';
14
14
  export { getDatabase, closeDatabase } from './database.js';
15
15
  export { initializeOpencodeForDirectory } from './opencode.js';
16
16
  export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js';
17
17
  export { getOpencodeSystemMessage } from './system-message.js';
18
- export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions } from './channel-management.js';
18
+ export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
19
19
  import { ChannelType, Client, Events, GatewayIntentBits, Partials, PermissionsBitField, ThreadAutoArchiveDuration, } from 'discord.js';
20
20
  import fs from 'node:fs';
21
21
  import { extractTagsArrays } from './xml.js';
@@ -32,12 +32,7 @@ export async function createDiscordClient() {
32
32
  GatewayIntentBits.MessageContent,
33
33
  GatewayIntentBits.GuildVoiceStates,
34
34
  ],
35
- partials: [
36
- Partials.Channel,
37
- Partials.Message,
38
- Partials.User,
39
- Partials.ThreadMember,
40
- ],
35
+ partials: [Partials.Channel, Partials.Message, Partials.User, Partials.ThreadMember],
41
36
  });
42
37
  }
43
38
  export async function startDiscordBot({ token, appId, discordClient, }) {
@@ -64,8 +59,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
64
59
  for (const guild of c.guilds.cache.values()) {
65
60
  discordLogger.log(`${guild.name} (${guild.id})`);
66
61
  const channels = await getChannelsWithDescriptions(guild);
67
- const kimakiChannels = channels.filter((ch) => ch.kimakiDirectory &&
68
- (!ch.kimakiApp || ch.kimakiApp === currentAppId));
62
+ const kimakiChannels = channels.filter((ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === currentAppId));
69
63
  if (kimakiChannels.length > 0) {
70
64
  discordLogger.log(` Found ${kimakiChannels.length} channel(s) for this bot:`);
71
65
  for (const channel of kimakiChannels) {
@@ -1,7 +1,7 @@
1
1
  // Discord-specific utility functions.
2
2
  // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
3
  // thread message sending, and channel metadata extraction from topic tags.
4
- import { ChannelType, } from 'discord.js';
4
+ import { ChannelType } from 'discord.js';
5
5
  import { Lexer } from 'marked';
6
6
  import { extractTagsArrays } from './xml.js';
7
7
  import { formatMarkdownTables } from './format-tables.js';
@@ -37,12 +37,30 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
37
37
  for (const token of tokens) {
38
38
  if (token.type === 'code') {
39
39
  const lang = token.lang || '';
40
- lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false });
40
+ lines.push({
41
+ text: '```' + lang + '\n',
42
+ inCodeBlock: false,
43
+ lang,
44
+ isOpeningFence: true,
45
+ isClosingFence: false,
46
+ });
41
47
  const codeLines = token.text.split('\n');
42
48
  for (const codeLine of codeLines) {
43
- lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false });
49
+ lines.push({
50
+ text: codeLine + '\n',
51
+ inCodeBlock: true,
52
+ lang,
53
+ isOpeningFence: false,
54
+ isClosingFence: false,
55
+ });
44
56
  }
45
- lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true });
57
+ lines.push({
58
+ text: '```\n',
59
+ inCodeBlock: false,
60
+ lang: '',
61
+ isOpeningFence: false,
62
+ isClosingFence: true,
63
+ });
46
64
  }
47
65
  else {
48
66
  const rawLines = token.raw.split('\n');
@@ -50,7 +68,13 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
50
68
  const isLast = i === rawLines.length - 1;
51
69
  const text = isLast ? rawLines[i] : rawLines[i] + '\n';
52
70
  if (text) {
53
- lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false });
71
+ lines.push({
72
+ text,
73
+ inCodeBlock: false,
74
+ lang: '',
75
+ isOpeningFence: false,
76
+ isClosingFence: false,
77
+ });
54
78
  }
55
79
  }
56
80
  }
@@ -93,7 +117,9 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
93
117
  currentChunk = '';
94
118
  }
95
119
  // calculate overhead for code block markers
96
- const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0;
120
+ const codeBlockOverhead = line.inCodeBlock
121
+ ? ('```' + line.lang + '\n').length + '```\n'.length
122
+ : 0;
97
123
  // ensure at least 10 chars available, even if maxLength is very small
98
124
  const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50);
99
125
  const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
@@ -204,9 +230,7 @@ export async function resolveTextChannel(channel) {
204
230
  return null;
205
231
  }
206
232
  export function escapeDiscordFormatting(text) {
207
- return text
208
- .replace(/```/g, '\\`\\`\\`')
209
- .replace(/````/g, '\\`\\`\\`\\`');
233
+ return text.replace(/```/g, '\\`\\`\\`').replace(/````/g, '\\`\\`\\`\\`');
210
234
  }
211
235
  export function getKimakiMetadata(textChannel) {
212
236
  if (!textChannel?.topic) {
package/dist/genai.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Google GenAI Live session manager for real-time voice interactions.
2
2
  // Establishes bidirectional audio streaming with Gemini, handles tool calls,
3
3
  // and manages the assistant's audio output for Discord voice channels.
4
- import { GoogleGenAI, LiveServerMessage, MediaResolution, Modality, Session, } from '@google/genai';
4
+ import { GoogleGenAI, LiveServerMessage, MediaResolution, Modality, Session } from '@google/genai';
5
5
  import { writeFile } from 'fs';
6
6
  import { createLogger } from './logger.js';
7
7
  import { aiToolToCallableTool } from './ai-tool-to-genai.js';
@@ -65,7 +65,7 @@ function createWavHeader(dataLength, options) {
65
65
  buffer.writeUInt32LE(dataLength, 40); // Subchunk2Size
66
66
  return buffer;
67
67
  }
68
- function defaultAudioChunkHandler({ data, mimeType, }) {
68
+ function defaultAudioChunkHandler({ data, mimeType }) {
69
69
  audioParts.push(data);
70
70
  const fileName = 'audio.wav';
71
71
  const buffer = convertToWav(audioParts, mimeType);
@@ -103,8 +103,7 @@ export async function startGenAiSession({ onAssistantAudioChunk, onAssistantStar
103
103
  }));
104
104
  if (functionResponses.length > 0 && session) {
105
105
  session.sendToolResponse({ functionResponses });
106
- genaiLogger.log('client-toolResponse: ' +
107
- JSON.stringify({ functionResponses }));
106
+ genaiLogger.log('client-toolResponse: ' + JSON.stringify({ functionResponses }));
108
107
  }
109
108
  })
110
109
  .catch((error) => {
@@ -120,8 +119,7 @@ export async function startGenAiSession({ onAssistantAudioChunk, onAssistantStar
120
119
  }
121
120
  if (part?.inlineData) {
122
121
  const inlineData = part.inlineData;
123
- if (!inlineData.mimeType ||
124
- !inlineData.mimeType.startsWith('audio/')) {
122
+ if (!inlineData.mimeType || !inlineData.mimeType.startsWith('audio/')) {
125
123
  genaiLogger.log('Skipping non-audio inlineData:', inlineData.mimeType);
126
124
  continue;
127
125
  }
@@ -5,12 +5,13 @@ import { Events } from 'discord.js';
5
5
  import { handleSessionCommand, handleSessionAutocomplete } from './commands/session.js';
6
6
  import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js';
7
7
  import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js';
8
+ import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './commands/remove-project.js';
8
9
  import { handleCreateNewProjectCommand } from './commands/create-new-project.js';
9
10
  import { handlePermissionSelectMenu } from './commands/permissions.js';
10
11
  import { handleAbortCommand } from './commands/abort.js';
11
12
  import { handleShareCommand } from './commands/share.js';
12
13
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
13
- import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu } from './commands/model.js';
14
+ import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, } from './commands/model.js';
14
15
  import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js';
15
16
  import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
16
17
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js';
@@ -38,6 +39,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
38
39
  case 'add-project':
39
40
  await handleAddProjectAutocomplete({ interaction, appId });
40
41
  return;
42
+ case 'remove-project':
43
+ await handleRemoveProjectAutocomplete({ interaction, appId });
44
+ return;
41
45
  default:
42
46
  await interaction.respond([]);
43
47
  return;
@@ -55,6 +59,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
55
59
  case 'add-project':
56
60
  await handleAddProjectCommand({ command: interaction, appId });
57
61
  return;
62
+ case 'remove-project':
63
+ await handleRemoveProjectCommand({ command: interaction, appId });
64
+ return;
58
65
  case 'create-new-project':
59
66
  await handleCreateNewProjectCommand({ command: interaction, appId });
60
67
  return;
package/dist/markdown.js CHANGED
@@ -262,9 +262,7 @@ export async function getCompactSessionContext({ client, sessionId, includeSyste
262
262
  lines.push('');
263
263
  }
264
264
  // Get tool calls in compact form (name + params only)
265
- const toolParts = (msg.parts || []).filter((p) => p.type === 'tool' &&
266
- 'state' in p &&
267
- p.state?.status === 'completed');
265
+ const toolParts = (msg.parts || []).filter((p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed');
268
266
  for (const part of toolParts) {
269
267
  if (part.type === 'tool' && 'tool' in part && 'state' in part) {
270
268
  const toolName = part.tool;
@@ -74,7 +74,7 @@ export async function getTextAttachments(message) {
74
74
  export async function getFileAttachments(message) {
75
75
  const fileAttachments = Array.from(message.attachments.values()).filter((attachment) => {
76
76
  const contentType = attachment.contentType || '';
77
- return (contentType.startsWith('image/') || contentType === 'application/pdf');
77
+ return contentType.startsWith('image/') || contentType === 'application/pdf';
78
78
  });
79
79
  if (fileAttachments.length === 0) {
80
80
  return [];
@@ -118,14 +118,18 @@ export function getToolSummaryText(part) {
118
118
  const added = newString.split('\n').length;
119
119
  const removed = oldString.split('\n').length;
120
120
  const fileName = filePath.split('/').pop() || '';
121
- return fileName ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})` : `(+${added}-${removed})`;
121
+ return fileName
122
+ ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})`
123
+ : `(+${added}-${removed})`;
122
124
  }
123
125
  if (part.tool === 'write') {
124
126
  const filePath = part.state.input?.filePath || '';
125
127
  const content = part.state.input?.content || '';
126
128
  const lines = content.split('\n').length;
127
129
  const fileName = filePath.split('/').pop() || '';
128
- return fileName ? `*${escapeInlineMarkdown(fileName)}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`;
130
+ return fileName
131
+ ? `*${escapeInlineMarkdown(fileName)}* (${lines} line${lines === 1 ? '' : 's'})`
132
+ : `(${lines} line${lines === 1 ? '' : 's'})`;
129
133
  }
130
134
  if (part.tool === 'webfetch') {
131
135
  const url = part.state.input?.url || '';
@@ -64,7 +64,7 @@ function createWavHeader(dataLength, options) {
64
64
  buffer.writeUInt32LE(dataLength, 40); // Subchunk2Size
65
65
  return buffer;
66
66
  }
67
- function defaultAudioChunkHandler({ data, mimeType, }) {
67
+ function defaultAudioChunkHandler({ data, mimeType }) {
68
68
  audioParts.push(data);
69
69
  const fileName = 'audio.wav';
70
70
  const buffer = convertToWav(audioParts, mimeType);
@@ -140,9 +140,7 @@ export async function startGenAiSession({ onAssistantAudioChunk, onAssistantStar
140
140
  }
141
141
  // Set up event handlers
142
142
  client.on('conversation.item.created', ({ item }) => {
143
- if ('role' in item &&
144
- item.role === 'assistant' &&
145
- item.type === 'message') {
143
+ if ('role' in item && item.role === 'assistant' && item.type === 'message') {
146
144
  // Check if this is the first audio content
147
145
  const hasAudio = 'content' in item &&
148
146
  Array.isArray(item.content) &&
@@ -153,7 +151,7 @@ export async function startGenAiSession({ onAssistantAudioChunk, onAssistantStar
153
151
  }
154
152
  }
155
153
  });
156
- client.on('conversation.updated', ({ item, delta, }) => {
154
+ client.on('conversation.updated', ({ item, delta }) => {
157
155
  // Handle audio chunks
158
156
  if (delta?.audio && 'role' in item && item.role === 'assistant') {
159
157
  if (!isAssistantSpeaking && onAssistantStartSpeaking) {
package/dist/opencode.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { spawn } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
6
  import net from 'node:net';
7
- import { createOpencodeClient, } from '@opencode-ai/sdk';
7
+ import { createOpencodeClient } from '@opencode-ai/sdk';
8
8
  import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
9
9
  import { createLogger } from './logger.js';
10
10
  const opencodeLogger = createLogger('OPENCODE');
@@ -87,8 +87,7 @@ export async function initializeOpencodeForDirectory(directory) {
87
87
  throw new Error(`Directory does not exist or is not accessible: ${directory}`);
88
88
  }
89
89
  const port = await getOpenPort();
90
- const opencodeBinDir = `${process.env.HOME}/.opencode/bin`;
91
- const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`;
90
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
92
91
  const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
93
92
  stdio: 'pipe',
94
93
  detached: false,
@@ -2,14 +2,14 @@
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, getSessionAgent, getChannelAgent } from './database.js';
6
- import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
5
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent, setSessionAgent, } from './database.js';
6
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2, } from './opencode.js';
7
7
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_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, cancelPendingQuestion, pendingQuestionContexts } from './commands/ask-question.js';
12
+ import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts, } from './commands/ask-question.js';
13
13
  import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
14
14
  const sessionLogger = createLogger('SESSION');
15
15
  const voiceLogger = createLogger('VOICE');
@@ -55,7 +55,9 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
55
55
  sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e);
56
56
  }
57
57
  // Small delay to let the abort propagate
58
- await new Promise((resolve) => { setTimeout(resolve, 300); });
58
+ await new Promise((resolve) => {
59
+ setTimeout(resolve, 300);
60
+ });
59
61
  // Fetch last user message from API
60
62
  sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`);
61
63
  const messagesResponse = await getClient().session.messages({ path: { id: sessionId } });
@@ -85,7 +87,7 @@ export async function abortAndRetrySession({ sessionId, thread, projectDirectory
85
87
  });
86
88
  return true;
87
89
  }
88
- export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, }) {
90
+ export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, agent, }) {
89
91
  voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
90
92
  const sessionStartTime = Date.now();
91
93
  const directory = projectDirectory || process.cwd();
@@ -127,6 +129,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
127
129
  .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
128
130
  .run(thread.id, session.id);
129
131
  sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`);
132
+ // Store agent preference if provided
133
+ if (agent) {
134
+ setSessionAgent(session.id, agent);
135
+ sessionLogger.log(`Set agent preference for session ${session.id}: ${agent}`);
136
+ }
130
137
  const existingController = abortControllers.get(session.id);
131
138
  if (existingController) {
132
139
  voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
@@ -162,7 +169,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
162
169
  const abortController = new AbortController();
163
170
  abortControllers.set(session.id, abortController);
164
171
  if (existingController) {
165
- await new Promise((resolve) => { setTimeout(resolve, 200); });
172
+ await new Promise((resolve) => {
173
+ setTimeout(resolve, 200);
174
+ });
166
175
  if (abortController.signal.aborted) {
167
176
  sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
168
177
  return;
@@ -186,8 +195,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
186
195
  sessionLogger.log(`Subscribed to OpenCode events`);
187
196
  const sentPartIds = new Set(getDatabase()
188
197
  .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
189
- .all(thread.id)
190
- .map((row) => row.part_id));
198
+ .all(thread.id).map((row) => row.part_id));
191
199
  let currentParts = [];
192
200
  let stopTyping = null;
193
201
  let usedModel;
@@ -259,7 +267,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
259
267
  continue;
260
268
  }
261
269
  if (msg.role === 'assistant') {
262
- const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write;
270
+ const newTokensTotal = msg.tokens.input +
271
+ msg.tokens.output +
272
+ msg.tokens.reasoning +
273
+ msg.tokens.cache.read +
274
+ msg.tokens.cache.write;
263
275
  if (newTokensTotal > 0) {
264
276
  tokensUsedInSession = newTokensTotal;
265
277
  }
@@ -270,7 +282,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
270
282
  if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
271
283
  if (!modelContextLimit) {
272
284
  try {
273
- const providersResponse = await getClient().provider.list({ query: { directory } });
285
+ const providersResponse = await getClient().provider.list({
286
+ query: { directory },
287
+ });
274
288
  const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
275
289
  const model = provider?.models?.[usedModel];
276
290
  if (model?.limit?.context) {
@@ -332,9 +346,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
332
346
  const outputTokens = Math.ceil(output.length / 4);
333
347
  const LARGE_OUTPUT_THRESHOLD = 3000;
334
348
  if (outputTokens >= LARGE_OUTPUT_THRESHOLD) {
335
- const formattedTokens = outputTokens >= 1000
336
- ? `${(outputTokens / 1000).toFixed(1)}k`
337
- : String(outputTokens);
349
+ const formattedTokens = outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens);
338
350
  const percentageSuffix = (() => {
339
351
  if (!modelContextLimit) {
340
352
  return '';
@@ -494,8 +506,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
494
506
  stopTyping();
495
507
  stopTyping = null;
496
508
  }
497
- if (!abortController.signal.aborted ||
498
- abortController.signal.reason === 'finished') {
509
+ if (!abortController.signal.aborted || abortController.signal.reason === 'finished') {
499
510
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
500
511
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
501
512
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
@@ -560,7 +571,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
560
571
  if (images.length === 0) {
561
572
  return prompt;
562
573
  }
563
- sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
574
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({
575
+ mime: img.mime,
576
+ filename: img.filename,
577
+ url: img.url.slice(0, 100),
578
+ })));
564
579
  const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n');
565
580
  return `${prompt}\n\n**attached images:**\n${imagePathsList}`;
566
581
  })();
@@ -648,15 +663,17 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
648
663
  discordLogger.log(`Could not update reaction:`, e);
649
664
  }
650
665
  }
651
- const errorName = error &&
652
- typeof error === 'object' &&
653
- 'constructor' in error &&
654
- error.constructor &&
655
- typeof error.constructor.name === 'string'
656
- ? error.constructor.name
657
- : typeof error;
658
- const errorMsg = error instanceof Error ? error.stack || error.message : String(error);
659
- await sendThreadMessage(thread, `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`);
666
+ const errorDisplay = (() => {
667
+ if (error instanceof Error) {
668
+ const name = error.constructor.name || 'Error';
669
+ return `[${name}]\n${error.stack || error.message}`;
670
+ }
671
+ if (typeof error === 'string') {
672
+ return error;
673
+ }
674
+ return String(error);
675
+ })();
676
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: ${errorDisplay}`);
660
677
  }
661
678
  }
662
679
  }
@@ -1,7 +1,7 @@
1
1
  // OpenCode system prompt generator.
2
2
  // Creates the system message injected into every OpenCode session,
3
3
  // including Discord-specific formatting rules, diff commands, and permissions info.
4
- export function getOpencodeSystemMessage({ sessionId, channelId }) {
4
+ export function getOpencodeSystemMessage({ sessionId, channelId, }) {
5
5
  return `
6
6
  The user is reading your messages from inside Discord, via kimaki.xyz
7
7
 
@@ -22,7 +22,8 @@ Only users with these Discord permissions can send messages to the bot:
22
22
  To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
23
23
 
24
24
  npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
25
- ${channelId ? `
25
+ ${channelId
26
+ ? `
26
27
  ## starting new sessions from CLI
27
28
 
28
29
  To start a new thread/session in this channel programmatically, run:
@@ -30,7 +31,8 @@ To start a new thread/session in this channel programmatically, run:
30
31
  npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
31
32
 
32
33
  This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
33
- ` : ''}
34
+ `
35
+ : ''}
34
36
  ## showing diffs
35
37
 
36
38
  IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.