kimaki 0.4.35 → 0.4.37

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 (76) hide show
  1. package/dist/ai-tool-to-genai.js +1 -3
  2. package/dist/channel-management.js +5 -5
  3. package/dist/cli.js +182 -46
  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 +2 -2
  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/database.js +7 -0
  18. package/dist/discord-bot.js +37 -20
  19. package/dist/discord-utils.js +33 -9
  20. package/dist/genai.js +4 -6
  21. package/dist/interaction-handler.js +8 -1
  22. package/dist/markdown.js +1 -3
  23. package/dist/message-formatting.js +7 -3
  24. package/dist/openai-realtime.js +3 -5
  25. package/dist/opencode.js +1 -1
  26. package/dist/session-handler.js +25 -15
  27. package/dist/system-message.js +10 -4
  28. package/dist/tools.js +9 -22
  29. package/dist/voice-handler.js +9 -12
  30. package/dist/voice.js +5 -3
  31. package/dist/xml.js +2 -4
  32. package/package.json +3 -2
  33. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  34. package/src/__snapshots__/compact-session-context.md +31 -31
  35. package/src/ai-tool-to-genai.ts +3 -11
  36. package/src/channel-management.ts +18 -29
  37. package/src/cli.ts +334 -205
  38. package/src/commands/abort.ts +1 -3
  39. package/src/commands/add-project.ts +8 -14
  40. package/src/commands/agent.ts +16 -9
  41. package/src/commands/ask-question.ts +8 -7
  42. package/src/commands/create-new-project.ts +8 -14
  43. package/src/commands/fork.ts +23 -27
  44. package/src/commands/model.ts +14 -11
  45. package/src/commands/permissions.ts +1 -1
  46. package/src/commands/queue.ts +6 -19
  47. package/src/commands/remove-project.ts +136 -0
  48. package/src/commands/resume.ts +11 -30
  49. package/src/commands/session.ts +4 -13
  50. package/src/commands/share.ts +1 -3
  51. package/src/commands/types.ts +1 -3
  52. package/src/commands/undo-redo.ts +6 -18
  53. package/src/commands/user-command.ts +8 -10
  54. package/src/config.ts +5 -5
  55. package/src/database.ts +17 -8
  56. package/src/discord-bot.ts +60 -58
  57. package/src/discord-utils.ts +35 -18
  58. package/src/escape-backticks.test.ts +0 -2
  59. package/src/format-tables.ts +1 -4
  60. package/src/genai-worker-wrapper.ts +3 -9
  61. package/src/genai-worker.ts +4 -19
  62. package/src/genai.ts +10 -42
  63. package/src/interaction-handler.ts +133 -121
  64. package/src/markdown.test.ts +10 -32
  65. package/src/markdown.ts +6 -14
  66. package/src/message-formatting.ts +13 -14
  67. package/src/openai-realtime.ts +25 -47
  68. package/src/opencode.ts +24 -34
  69. package/src/session-handler.ts +91 -61
  70. package/src/system-message.ts +18 -4
  71. package/src/tools.ts +13 -39
  72. package/src/utils.ts +1 -4
  73. package/src/voice-handler.ts +34 -78
  74. package/src/voice.ts +11 -19
  75. package/src/xml.test.ts +1 -1
  76. package/src/xml.ts +3 -12
@@ -3,12 +3,12 @@ import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
3
3
  import fs from 'node:fs';
4
4
  import { getDatabase } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
- import { sendThreadMessage, resolveTextChannel, getKimakiMetadata, } from '../discord-utils.js';
6
+ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
7
  import { extractTagsArrays } from '../xml.js';
8
8
  import { collectLastAssistantParts } from '../message-formatting.js';
9
9
  import { createLogger } from '../logger.js';
10
10
  const logger = createLogger('RESUME');
11
- export async function handleResumeCommand({ command, appId, }) {
11
+ export async function handleResumeCommand({ command, appId }) {
12
12
  await command.deferReply({ ephemeral: false });
13
13
  const sessionId = command.options.getString('session', true);
14
14
  const channel = command.channel;
@@ -116,9 +116,7 @@ export async function handleResumeAutocomplete({ interaction, appId, }) {
116
116
  await interaction.respond([]);
117
117
  return;
118
118
  }
119
- const existingSessionIds = new Set(getDatabase()
120
- .prepare('SELECT session_id FROM thread_sessions')
121
- .all().map((row) => row.session_id));
119
+ const existingSessionIds = new Set(getDatabase().prepare('SELECT session_id FROM thread_sessions').all().map((row) => row.session_id));
122
120
  const sessions = sessionsResponse.data
123
121
  .filter((session) => !existingSessionIds.has(session.id))
124
122
  .filter((session) => session.title.toLowerCase().includes(focusedValue.toLowerCase()))
@@ -9,7 +9,7 @@ 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') || '';
@@ -75,7 +75,7 @@ export async function handleSessionCommand({ command, appId, }) {
75
75
  await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
76
76
  }
77
77
  }
78
- async function handleAgentAutocomplete({ interaction, appId, }) {
78
+ async function handleAgentAutocomplete({ interaction, appId }) {
79
79
  const focusedValue = interaction.options.getFocused();
80
80
  let projectDirectory;
81
81
  if (interaction.channel) {
@@ -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
package/dist/database.js CHANGED
@@ -50,6 +50,13 @@ export function getDatabase() {
50
50
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
51
51
  )
52
52
  `);
53
+ // Migration: add app_id column to channel_directories for multi-bot support
54
+ try {
55
+ db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`);
56
+ }
57
+ catch {
58
+ // Column already exists, ignore
59
+ }
53
60
  db.exec(`
54
61
  CREATE TABLE IF NOT EXISTS bot_api_keys (
55
62
  app_id TEXT PRIMARY KEY,
@@ -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) {
@@ -125,14 +119,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
125
119
  if (isThread) {
126
120
  const thread = channel;
127
121
  discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
128
- const row = getDatabase()
129
- .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
130
- .get(thread.id);
131
- if (!row) {
132
- discordLogger.log(`No session found for thread ${thread.id}`);
133
- return;
134
- }
135
- voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
136
122
  const parent = thread.parent;
137
123
  let projectDirectory;
138
124
  let channelAppId;
@@ -156,6 +142,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
156
142
  });
157
143
  return;
158
144
  }
145
+ const row = getDatabase()
146
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
147
+ .get(thread.id);
148
+ // No existing session - start a new one (e.g., replying to a notification thread)
149
+ if (!row) {
150
+ discordLogger.log(`No session for thread ${thread.id}, starting new session`);
151
+ if (!projectDirectory) {
152
+ discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`);
153
+ return;
154
+ }
155
+ // Include starter message (notification) as context for the session
156
+ let prompt = message.content;
157
+ const starterMessage = await thread.fetchStarterMessage().catch(() => null);
158
+ if (starterMessage?.content) {
159
+ // Strip notification prefix if present
160
+ const notificationContent = starterMessage.content
161
+ .replace(/^📢 \*\*Notification\*\*\n?/, '')
162
+ .trim();
163
+ if (notificationContent) {
164
+ prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`;
165
+ }
166
+ }
167
+ await handleOpencodeSession({
168
+ prompt,
169
+ thread,
170
+ projectDirectory,
171
+ channelId: parent?.id || '',
172
+ });
173
+ return;
174
+ }
175
+ voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
159
176
  let messageContent = message.content || '';
160
177
  let currentSessionContext;
161
178
  let lastSessionContext;
@@ -299,9 +316,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
299
316
  }
300
317
  }
301
318
  });
302
- // Magic prefix used by `kimaki start-session` CLI command to initiate sessions
319
+ // Magic prefix used by `kimaki send` CLI command to initiate sessions
303
320
  const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
304
- // Handle bot-initiated threads created by `kimaki start-session`
321
+ // Handle bot-initiated threads created by `kimaki send`
305
322
  discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
306
323
  try {
307
324
  if (!newlyCreated) {
@@ -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');
@@ -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, setSessionAgent } 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 } });
@@ -167,7 +169,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
167
169
  const abortController = new AbortController();
168
170
  abortControllers.set(session.id, abortController);
169
171
  if (existingController) {
170
- await new Promise((resolve) => { setTimeout(resolve, 200); });
172
+ await new Promise((resolve) => {
173
+ setTimeout(resolve, 200);
174
+ });
171
175
  if (abortController.signal.aborted) {
172
176
  sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
173
177
  return;
@@ -191,8 +195,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
191
195
  sessionLogger.log(`Subscribed to OpenCode events`);
192
196
  const sentPartIds = new Set(getDatabase()
193
197
  .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
194
- .all(thread.id)
195
- .map((row) => row.part_id));
198
+ .all(thread.id).map((row) => row.part_id));
196
199
  let currentParts = [];
197
200
  let stopTyping = null;
198
201
  let usedModel;
@@ -264,7 +267,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
264
267
  continue;
265
268
  }
266
269
  if (msg.role === 'assistant') {
267
- 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;
268
275
  if (newTokensTotal > 0) {
269
276
  tokensUsedInSession = newTokensTotal;
270
277
  }
@@ -275,7 +282,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
275
282
  if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
276
283
  if (!modelContextLimit) {
277
284
  try {
278
- const providersResponse = await getClient().provider.list({ query: { directory } });
285
+ const providersResponse = await getClient().provider.list({
286
+ query: { directory },
287
+ });
279
288
  const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
280
289
  const model = provider?.models?.[usedModel];
281
290
  if (model?.limit?.context) {
@@ -337,9 +346,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
337
346
  const outputTokens = Math.ceil(output.length / 4);
338
347
  const LARGE_OUTPUT_THRESHOLD = 3000;
339
348
  if (outputTokens >= LARGE_OUTPUT_THRESHOLD) {
340
- const formattedTokens = outputTokens >= 1000
341
- ? `${(outputTokens / 1000).toFixed(1)}k`
342
- : String(outputTokens);
349
+ const formattedTokens = outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens);
343
350
  const percentageSuffix = (() => {
344
351
  if (!modelContextLimit) {
345
352
  return '';
@@ -499,8 +506,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
499
506
  stopTyping();
500
507
  stopTyping = null;
501
508
  }
502
- if (!abortController.signal.aborted ||
503
- abortController.signal.reason === 'finished') {
509
+ if (!abortController.signal.aborted || abortController.signal.reason === 'finished') {
504
510
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
505
511
  const attachCommand = port ? ` ⋅ ${session.id}` : '';
506
512
  const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
@@ -565,7 +571,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
565
571
  if (images.length === 0) {
566
572
  return prompt;
567
573
  }
568
- 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
+ })));
569
579
  const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n');
570
580
  return `${prompt}\n\n**attached images:**\n${imagePathsList}`;
571
581
  })();
@@ -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,15 +22,21 @@ 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:
29
30
 
30
- npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
31
+ npx -y kimaki send --channel ${channelId} --prompt "your prompt here"
32
+
33
+ Use --notify-only to create a notification thread without starting an AI session:
34
+
35
+ npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
31
36
 
32
37
  This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
33
- ` : ''}
38
+ `
39
+ : ''}
34
40
  ## showing diffs
35
41
 
36
42
  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.