kimaki 0.4.25 → 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 (50) hide show
  1. package/dist/acp-client.test.js +149 -0
  2. package/dist/channel-management.js +11 -9
  3. package/dist/cli.js +59 -7
  4. package/dist/commands/add-project.js +1 -0
  5. package/dist/commands/agent.js +152 -0
  6. package/dist/commands/ask-question.js +183 -0
  7. package/dist/commands/model.js +23 -4
  8. package/dist/commands/session.js +1 -3
  9. package/dist/commands/user-command.js +145 -0
  10. package/dist/database.js +51 -0
  11. package/dist/discord-bot.js +32 -32
  12. package/dist/discord-utils.js +71 -14
  13. package/dist/interaction-handler.js +20 -0
  14. package/dist/logger.js +43 -5
  15. package/dist/markdown.js +104 -0
  16. package/dist/markdown.test.js +31 -1
  17. package/dist/message-formatting.js +72 -22
  18. package/dist/message-formatting.test.js +73 -0
  19. package/dist/opencode.js +70 -16
  20. package/dist/session-handler.js +131 -62
  21. package/dist/system-message.js +4 -51
  22. package/dist/voice-handler.js +18 -8
  23. package/dist/voice.js +28 -12
  24. package/package.json +14 -13
  25. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  26. package/src/__snapshots__/compact-session-context.md +47 -0
  27. package/src/channel-management.ts +20 -8
  28. package/src/cli.ts +74 -8
  29. package/src/commands/add-project.ts +1 -0
  30. package/src/commands/agent.ts +201 -0
  31. package/src/commands/ask-question.ts +276 -0
  32. package/src/commands/fork.ts +1 -2
  33. package/src/commands/model.ts +24 -4
  34. package/src/commands/session.ts +1 -3
  35. package/src/commands/user-command.ts +178 -0
  36. package/src/database.ts +61 -0
  37. package/src/discord-bot.ts +36 -33
  38. package/src/discord-utils.ts +76 -14
  39. package/src/interaction-handler.ts +25 -0
  40. package/src/logger.ts +47 -10
  41. package/src/markdown.test.ts +45 -1
  42. package/src/markdown.ts +132 -0
  43. package/src/message-formatting.test.ts +81 -0
  44. package/src/message-formatting.ts +93 -25
  45. package/src/opencode.ts +80 -21
  46. package/src/session-handler.ts +180 -90
  47. package/src/system-message.ts +4 -51
  48. package/src/voice-handler.ts +20 -9
  49. package/src/voice.ts +32 -13
  50. package/LICENSE +0 -21
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
4
4
  import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
+ import { abortAndRetrySession } from '../session-handler.js';
7
8
  import { createLogger } from '../logger.js';
8
9
  const modelLogger = createLogger('MODEL');
9
10
  // Store context by hash to avoid customId length limits (Discord max: 100 chars)
@@ -102,6 +103,7 @@ export async function handleModelCommand({ interaction, appId, }) {
102
103
  channelId: targetChannelId,
103
104
  sessionId: sessionId,
104
105
  isThread: isThread,
106
+ thread: isThread ? channel : undefined,
105
107
  };
106
108
  const contextHash = crypto.randomBytes(8).toString('hex');
107
109
  pendingModelContexts.set(contextHash, context);
@@ -267,10 +269,27 @@ export async function handleModelSelectMenu(interaction) {
267
269
  // Store for session
268
270
  setSessionModel(context.sessionId, fullModelId);
269
271
  modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`);
270
- await interaction.editReply({
271
- content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
272
- components: [],
273
- });
272
+ // Check if there's a running request and abort+retry with new model
273
+ let retried = false;
274
+ if (context.thread) {
275
+ retried = await abortAndRetrySession({
276
+ sessionId: context.sessionId,
277
+ thread: context.thread,
278
+ projectDirectory: context.dir,
279
+ });
280
+ }
281
+ if (retried) {
282
+ await interaction.editReply({
283
+ content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\n_Retrying current request with new model..._`,
284
+ components: [],
285
+ });
286
+ }
287
+ else {
288
+ await interaction.editReply({
289
+ content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
290
+ components: [],
291
+ });
292
+ }
274
293
  }
275
294
  else {
276
295
  // Store for channel
@@ -6,7 +6,7 @@ import { getDatabase } from '../database.js';
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
7
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
8
8
  import { extractTagsArrays } from '../xml.js';
9
- import { handleOpencodeSession, parseSlashCommand } from '../session-handler.js';
9
+ import { handleOpencodeSession } from '../session-handler.js';
10
10
  import { createLogger } from '../logger.js';
11
11
  const logger = createLogger('SESSION');
12
12
  export async function handleSessionCommand({ command, appId, }) {
@@ -61,12 +61,10 @@ export async function handleSessionCommand({ command, appId, }) {
61
61
  reason: 'OpenCode session',
62
62
  });
63
63
  await command.editReply(`Created new session in ${thread.toString()}`);
64
- const parsedCommand = parseSlashCommand(fullPrompt);
65
64
  await handleOpencodeSession({
66
65
  prompt: fullPrompt,
67
66
  thread,
68
67
  projectDirectory,
69
- parsedCommand,
70
68
  channelId: textChannel.id,
71
69
  });
72
70
  }
@@ -0,0 +1,145 @@
1
+ // User-defined OpenCode command handler.
2
+ // Handles slash commands that map to user-configured commands in opencode.json.
3
+ import { ChannelType } from 'discord.js';
4
+ import { extractTagsArrays } from '../xml.js';
5
+ import { handleOpencodeSession } from '../session-handler.js';
6
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
7
+ import { createLogger } from '../logger.js';
8
+ import { getDatabase } from '../database.js';
9
+ import fs from 'node:fs';
10
+ const userCommandLogger = createLogger('USER_CMD');
11
+ export const handleUserCommand = async ({ command, appId, }) => {
12
+ const discordCommandName = command.commandName;
13
+ // Strip the -cmd suffix to get the actual OpenCode command name
14
+ const commandName = discordCommandName.replace(/-cmd$/, '');
15
+ const args = command.options.getString('arguments') || '';
16
+ userCommandLogger.log(`Executing /${commandName} (from /${discordCommandName}) with args: ${args}`);
17
+ const channel = command.channel;
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);
24
+ const isTextChannel = channel?.type === ChannelType.GuildText;
25
+ if (!channel || (!isTextChannel && !isThread)) {
26
+ await command.reply({
27
+ content: 'This command can only be used in text channels or threads',
28
+ ephemeral: true,
29
+ });
30
+ return;
31
+ }
32
+ let projectDirectory;
33
+ let channelAppId;
34
+ let textChannel = null;
35
+ let thread = null;
36
+ if (isThread) {
37
+ // Running in an existing thread - get project directory from parent channel
38
+ thread = channel;
39
+ textChannel = thread.parent;
40
+ // Verify this thread has an existing session
41
+ const row = getDatabase()
42
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
43
+ .get(thread.id);
44
+ if (!row) {
45
+ await command.reply({
46
+ content: 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
47
+ ephemeral: true,
48
+ });
49
+ return;
50
+ }
51
+ if (textChannel?.topic) {
52
+ const extracted = extractTagsArrays({
53
+ xml: textChannel.topic,
54
+ tags: ['kimaki.directory', 'kimaki.app'],
55
+ });
56
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
57
+ channelAppId = extracted['kimaki.app']?.[0]?.trim();
58
+ }
59
+ }
60
+ else {
61
+ // Running in a text channel - will create a new thread
62
+ textChannel = channel;
63
+ if (textChannel.topic) {
64
+ const extracted = extractTagsArrays({
65
+ xml: textChannel.topic,
66
+ tags: ['kimaki.directory', 'kimaki.app'],
67
+ });
68
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
69
+ channelAppId = extracted['kimaki.app']?.[0]?.trim();
70
+ }
71
+ }
72
+ if (channelAppId && channelAppId !== appId) {
73
+ await command.reply({
74
+ content: 'This channel is not configured for this bot',
75
+ ephemeral: true,
76
+ });
77
+ return;
78
+ }
79
+ if (!projectDirectory) {
80
+ await command.reply({
81
+ content: 'This channel is not configured with a project directory',
82
+ ephemeral: true,
83
+ });
84
+ return;
85
+ }
86
+ if (!fs.existsSync(projectDirectory)) {
87
+ await command.reply({
88
+ content: `Directory does not exist: ${projectDirectory}`,
89
+ ephemeral: true,
90
+ });
91
+ return;
92
+ }
93
+ await command.deferReply({ ephemeral: false });
94
+ try {
95
+ // Use the dedicated session.command API instead of formatting as text prompt
96
+ const commandPayload = { name: commandName, arguments: args };
97
+ if (isThread && thread) {
98
+ // Running in existing thread - just send the command
99
+ await command.editReply(`Running /${commandName}...`);
100
+ await handleOpencodeSession({
101
+ prompt: '', // Not used when command is set
102
+ thread,
103
+ projectDirectory,
104
+ channelId: textChannel?.id,
105
+ command: commandPayload,
106
+ });
107
+ }
108
+ else if (textChannel) {
109
+ // Running in text channel - create a new thread
110
+ const starterMessage = await textChannel.send({
111
+ content: `**/${commandName}**${args ? ` ${args.slice(0, 200)}${args.length > 200 ? '…' : ''}` : ''}`,
112
+ flags: SILENT_MESSAGE_FLAGS,
113
+ });
114
+ const threadName = `/${commandName} ${args.slice(0, 80)}${args.length > 80 ? '…' : ''}`;
115
+ const newThread = await starterMessage.startThread({
116
+ name: threadName.slice(0, 100),
117
+ autoArchiveDuration: 1440,
118
+ reason: `OpenCode command: ${commandName}`,
119
+ });
120
+ await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
121
+ await handleOpencodeSession({
122
+ prompt: '', // Not used when command is set
123
+ thread: newThread,
124
+ projectDirectory,
125
+ channelId: textChannel.id,
126
+ command: commandPayload,
127
+ });
128
+ }
129
+ }
130
+ catch (error) {
131
+ userCommandLogger.error(`Error executing /${commandName}:`, error);
132
+ const errorMessage = error instanceof Error ? error.message : String(error);
133
+ if (command.deferred) {
134
+ await command.editReply({
135
+ content: `Failed to execute /${commandName}: ${errorMessage}`,
136
+ });
137
+ }
138
+ else {
139
+ await command.reply({
140
+ content: `Failed to execute /${commandName}: ${errorMessage}`,
141
+ ephemeral: true,
142
+ });
143
+ }
144
+ }
145
+ };
package/dist/database.js CHANGED
@@ -82,6 +82,21 @@ export function runModelMigrations(database) {
82
82
  model_id TEXT NOT NULL,
83
83
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
84
84
  )
85
+ `);
86
+ targetDb.exec(`
87
+ CREATE TABLE IF NOT EXISTS channel_agents (
88
+ channel_id TEXT PRIMARY KEY,
89
+ agent_name TEXT NOT NULL,
90
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
91
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
92
+ )
93
+ `);
94
+ targetDb.exec(`
95
+ CREATE TABLE IF NOT EXISTS session_agents (
96
+ session_id TEXT PRIMARY KEY,
97
+ agent_name TEXT NOT NULL,
98
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
99
+ )
85
100
  `);
86
101
  dbLogger.log('Model preferences migrations complete');
87
102
  }
@@ -125,6 +140,42 @@ export function setSessionModel(sessionId, modelId) {
125
140
  const db = getDatabase();
126
141
  db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
127
142
  }
143
+ /**
144
+ * Get the agent preference for a channel.
145
+ */
146
+ export function getChannelAgent(channelId) {
147
+ const db = getDatabase();
148
+ const row = db
149
+ .prepare('SELECT agent_name FROM channel_agents WHERE channel_id = ?')
150
+ .get(channelId);
151
+ return row?.agent_name;
152
+ }
153
+ /**
154
+ * Set the agent preference for a channel.
155
+ */
156
+ export function setChannelAgent(channelId, agentName) {
157
+ const db = getDatabase();
158
+ db.prepare(`INSERT INTO channel_agents (channel_id, agent_name, updated_at)
159
+ VALUES (?, ?, CURRENT_TIMESTAMP)
160
+ ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, agentName, agentName);
161
+ }
162
+ /**
163
+ * Get the agent preference for a session.
164
+ */
165
+ export function getSessionAgent(sessionId) {
166
+ const db = getDatabase();
167
+ const row = db
168
+ .prepare('SELECT agent_name FROM session_agents WHERE session_id = ?')
169
+ .get(sessionId);
170
+ return row?.agent_name;
171
+ }
172
+ /**
173
+ * Set the agent preference for a session.
174
+ */
175
+ export function setSessionAgent(sessionId, agentName) {
176
+ const db = getDatabase();
177
+ db.prepare(`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`).run(sessionId, agentName);
178
+ }
128
179
  export function closeDatabase() {
129
180
  if (db) {
130
181
  db.close();
@@ -8,7 +8,8 @@ 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 { handleOpencodeSession, parseSlashCommand, } from './session-handler.js';
11
+ import { getCompactSessionContext, getLastSessionId, } from './markdown.js';
12
+ import { handleOpencodeSession } from './session-handler.js';
12
13
  import { registerInteractionHandler } from './interaction-handler.js';
13
14
  export { getDatabase, closeDatabase } from './database.js';
14
15
  export { initializeOpencodeForDirectory } from './opencode.js';
@@ -153,35 +154,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
153
154
  return;
154
155
  }
155
156
  let messageContent = message.content || '';
156
- let sessionMessagesText;
157
- if (projectDirectory && row.session_id) {
157
+ let currentSessionContext;
158
+ let lastSessionContext;
159
+ if (projectDirectory) {
158
160
  try {
159
161
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
160
- const messagesResponse = await getClient().session.messages({
161
- path: { id: row.session_id },
162
+ const client = getClient();
163
+ // get current session context (without system prompt, it would be duplicated)
164
+ if (row.session_id) {
165
+ currentSessionContext = await getCompactSessionContext({
166
+ client,
167
+ sessionId: row.session_id,
168
+ includeSystemPrompt: false,
169
+ maxMessages: 15,
170
+ });
171
+ }
172
+ // get last session context (with system prompt for project context)
173
+ const lastSessionId = await getLastSessionId({
174
+ client,
175
+ excludeSessionId: row.session_id,
162
176
  });
163
- const messages = messagesResponse.data || [];
164
- const recentMessages = messages.slice(-10);
165
- sessionMessagesText = recentMessages
166
- .map((m) => {
167
- const role = m.info.role === 'user' ? 'User' : 'Assistant';
168
- const text = (() => {
169
- if (m.info.role === 'user') {
170
- const textParts = (m.parts || []).filter((p) => p.type === 'text');
171
- return textParts
172
- .map((p) => ('text' in p ? p.text : ''))
173
- .filter(Boolean)
174
- .join('\n');
175
- }
176
- const assistantInfo = m.info;
177
- return assistantInfo.text?.slice(0, 500);
178
- })();
179
- return `[${role}]: ${text || '(no text)'}`;
180
- })
181
- .join('\n\n');
177
+ if (lastSessionId) {
178
+ lastSessionContext = await getCompactSessionContext({
179
+ client,
180
+ sessionId: lastSessionId,
181
+ includeSystemPrompt: true,
182
+ maxMessages: 10,
183
+ });
184
+ }
182
185
  }
183
186
  catch (e) {
184
- voiceLogger.log(`Could not get session messages:`, e);
187
+ voiceLogger.error(`Could not get session context:`, e);
185
188
  }
186
189
  }
187
190
  const transcription = await processVoiceAttachment({
@@ -189,24 +192,23 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
189
192
  thread,
190
193
  projectDirectory,
191
194
  appId: currentAppId,
192
- sessionMessages: sessionMessagesText,
195
+ currentSessionContext,
196
+ lastSessionContext,
193
197
  });
194
198
  if (transcription) {
195
199
  messageContent = transcription;
196
200
  }
197
- const fileAttachments = getFileAttachments(message);
201
+ const fileAttachments = await getFileAttachments(message);
198
202
  const textAttachmentsContent = await getTextAttachments(message);
199
203
  const promptWithAttachments = textAttachmentsContent
200
204
  ? `${messageContent}\n\n${textAttachmentsContent}`
201
205
  : messageContent;
202
- const parsedCommand = parseSlashCommand(messageContent);
203
206
  await handleOpencodeSession({
204
207
  prompt: promptWithAttachments,
205
208
  thread,
206
209
  projectDirectory,
207
210
  originalMessage: message,
208
211
  images: fileAttachments,
209
- parsedCommand,
210
212
  channelId: parent?.id,
211
213
  });
212
214
  return;
@@ -265,19 +267,17 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
265
267
  if (transcription) {
266
268
  messageContent = transcription;
267
269
  }
268
- const fileAttachments = getFileAttachments(message);
270
+ const fileAttachments = await getFileAttachments(message);
269
271
  const textAttachmentsContent = await getTextAttachments(message);
270
272
  const promptWithAttachments = textAttachmentsContent
271
273
  ? `${messageContent}\n\n${textAttachmentsContent}`
272
274
  : messageContent;
273
- const parsedCommand = parseSlashCommand(messageContent);
274
275
  await handleOpencodeSession({
275
276
  prompt: promptWithAttachments,
276
277
  thread,
277
278
  projectDirectory,
278
279
  originalMessage: message,
279
280
  images: fileAttachments,
280
- parsedCommand,
281
281
  channelId: textChannel.id,
282
282
  });
283
283
  }
@@ -56,29 +56,86 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
56
56
  const chunks = [];
57
57
  let currentChunk = '';
58
58
  let currentLang = null;
59
+ // helper to split a long line into smaller pieces at word boundaries or hard breaks
60
+ const splitLongLine = (text, available, inCode) => {
61
+ const pieces = [];
62
+ let remaining = text;
63
+ while (remaining.length > available) {
64
+ let splitAt = available;
65
+ // for non-code, try to split at word boundary
66
+ if (!inCode) {
67
+ const lastSpace = remaining.lastIndexOf(' ', available);
68
+ if (lastSpace > available * 0.5) {
69
+ splitAt = lastSpace + 1;
70
+ }
71
+ }
72
+ pieces.push(remaining.slice(0, splitAt));
73
+ remaining = remaining.slice(splitAt);
74
+ }
75
+ if (remaining) {
76
+ pieces.push(remaining);
77
+ }
78
+ return pieces;
79
+ };
59
80
  for (const line of lines) {
60
81
  const wouldExceed = currentChunk.length + line.text.length > maxLength;
61
- if (wouldExceed && currentChunk) {
62
- if (currentLang !== null) {
63
- currentChunk += '```\n';
64
- }
65
- chunks.push(currentChunk);
66
- if (line.isClosingFence && currentLang !== null) {
67
- currentChunk = '';
82
+ if (wouldExceed) {
83
+ // handle case where single line is longer than maxLength
84
+ if (line.text.length > maxLength) {
85
+ // first, flush current chunk if any
86
+ if (currentChunk) {
87
+ if (currentLang !== null) {
88
+ currentChunk += '```\n';
89
+ }
90
+ chunks.push(currentChunk);
91
+ currentChunk = '';
92
+ }
93
+ // calculate overhead for code block markers
94
+ const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0;
95
+ const availablePerChunk = maxLength - codeBlockOverhead - 50; // safety margin
96
+ const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
97
+ for (let i = 0; i < pieces.length; i++) {
98
+ const piece = pieces[i];
99
+ if (line.inCodeBlock) {
100
+ chunks.push('```' + line.lang + '\n' + piece + '```\n');
101
+ }
102
+ else {
103
+ chunks.push(piece);
104
+ }
105
+ }
68
106
  currentLang = null;
69
107
  continue;
70
108
  }
71
- if (line.inCodeBlock || line.isOpeningFence) {
72
- const lang = line.lang;
73
- currentChunk = '```' + lang + '\n';
74
- if (!line.isOpeningFence) {
75
- currentChunk += line.text;
109
+ // normal case: line fits in a chunk but current chunk would overflow
110
+ if (currentChunk) {
111
+ if (currentLang !== null) {
112
+ currentChunk += '```\n';
113
+ }
114
+ chunks.push(currentChunk);
115
+ if (line.isClosingFence && currentLang !== null) {
116
+ currentChunk = '';
117
+ currentLang = null;
118
+ continue;
119
+ }
120
+ if (line.inCodeBlock || line.isOpeningFence) {
121
+ const lang = line.lang;
122
+ currentChunk = '```' + lang + '\n';
123
+ if (!line.isOpeningFence) {
124
+ currentChunk += line.text;
125
+ }
126
+ currentLang = lang;
127
+ }
128
+ else {
129
+ currentChunk = line.text;
130
+ currentLang = null;
76
131
  }
77
- currentLang = lang;
78
132
  }
79
133
  else {
134
+ // currentChunk is empty but line still exceeds - shouldn't happen after above check
80
135
  currentChunk = line.text;
81
- currentLang = null;
136
+ if (line.inCodeBlock || line.isOpeningFence) {
137
+ currentLang = line.lang;
138
+ }
82
139
  }
83
140
  }
84
141
  else {
@@ -11,8 +11,11 @@ import { handleAbortCommand } from './commands/abort.js';
11
11
  import { handleShareCommand } from './commands/share.js';
12
12
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
13
13
  import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu } from './commands/model.js';
14
+ import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js';
15
+ import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
14
16
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js';
15
17
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
18
+ import { handleUserCommand } from './commands/user-command.js';
16
19
  import { createLogger } from './logger.js';
17
20
  const interactionLogger = createLogger('INTERACTION');
18
21
  export function registerInteractionHandler({ discordClient, appId, }) {
@@ -63,6 +66,7 @@ export function registerInteractionHandler({ discordClient, appId, }) {
63
66
  await handleRejectCommand({ command: interaction, appId });
64
67
  return;
65
68
  case 'abort':
69
+ case 'stop':
66
70
  await handleAbortCommand({ command: interaction, appId });
67
71
  return;
68
72
  case 'share':
@@ -74,6 +78,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
74
78
  case 'model':
75
79
  await handleModelCommand({ interaction, appId });
76
80
  return;
81
+ case 'agent':
82
+ await handleAgentCommand({ interaction, appId });
83
+ return;
77
84
  case 'queue':
78
85
  await handleQueueCommand({ command: interaction, appId });
79
86
  return;
@@ -87,6 +94,11 @@ export function registerInteractionHandler({ discordClient, appId, }) {
87
94
  await handleRedoCommand({ command: interaction, appId });
88
95
  return;
89
96
  }
97
+ // Handle user-defined commands (ending with -cmd suffix)
98
+ if (interaction.commandName.endsWith('-cmd')) {
99
+ await handleUserCommand({ command: interaction, appId });
100
+ return;
101
+ }
90
102
  return;
91
103
  }
92
104
  if (interaction.isStringSelectMenu()) {
@@ -103,6 +115,14 @@ export function registerInteractionHandler({ discordClient, appId, }) {
103
115
  await handleModelSelectMenu(interaction);
104
116
  return;
105
117
  }
118
+ if (customId.startsWith('agent_select:')) {
119
+ await handleAgentSelectMenu(interaction);
120
+ return;
121
+ }
122
+ if (customId.startsWith('ask_question:')) {
123
+ await handleAskQuestionSelectMenu(interaction);
124
+ return;
125
+ }
106
126
  return;
107
127
  }
108
128
  }
package/dist/logger.js CHANGED
@@ -2,12 +2,50 @@
2
2
  // Creates loggers with consistent prefixes for different subsystems
3
3
  // (DISCORD, VOICE, SESSION, etc.) for easier debugging.
4
4
  import { log } from '@clack/prompts';
5
+ import fs from 'node:fs';
6
+ import path, { dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const isDev = !__dirname.includes('node_modules');
11
+ const logFilePath = path.join(__dirname, '..', 'tmp', 'kimaki.log');
12
+ // reset log file on startup in dev mode
13
+ if (isDev) {
14
+ const logDir = path.dirname(logFilePath);
15
+ if (!fs.existsSync(logDir)) {
16
+ fs.mkdirSync(logDir, { recursive: true });
17
+ }
18
+ fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`);
19
+ }
20
+ function writeToFile(level, prefix, args) {
21
+ if (!isDev) {
22
+ return;
23
+ }
24
+ const timestamp = new Date().toISOString();
25
+ const message = `[${timestamp}] [${level}] [${prefix}] ${args.map((arg) => String(arg)).join(' ')}\n`;
26
+ fs.appendFileSync(logFilePath, message);
27
+ }
5
28
  export function createLogger(prefix) {
6
29
  return {
7
- log: (...args) => log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
8
- error: (...args) => log.error([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
9
- warn: (...args) => log.warn([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
10
- info: (...args) => log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
11
- debug: (...args) => log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
30
+ log: (...args) => {
31
+ writeToFile('INFO', prefix, args);
32
+ log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
33
+ },
34
+ error: (...args) => {
35
+ writeToFile('ERROR', prefix, args);
36
+ log.error([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
37
+ },
38
+ warn: (...args) => {
39
+ writeToFile('WARN', prefix, args);
40
+ log.warn([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
41
+ },
42
+ info: (...args) => {
43
+ writeToFile('INFO', prefix, args);
44
+ log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
45
+ },
46
+ debug: (...args) => {
47
+ writeToFile('DEBUG', prefix, args);
48
+ log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
49
+ },
12
50
  };
13
51
  }