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.
- package/dist/acp-client.test.js +149 -0
- package/dist/channel-management.js +11 -9
- package/dist/cli.js +59 -7
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/model.js +23 -4
- package/dist/commands/session.js +1 -3
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +51 -0
- package/dist/discord-bot.js +32 -32
- package/dist/discord-utils.js +71 -14
- package/dist/interaction-handler.js +20 -0
- package/dist/logger.js +43 -5
- package/dist/markdown.js +104 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +72 -22
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +70 -16
- package/dist/session-handler.js +131 -62
- package/dist/system-message.js +4 -51
- package/dist/voice-handler.js +18 -8
- package/dist/voice.js +28 -12
- package/package.json +14 -13
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/channel-management.ts +20 -8
- package/src/cli.ts +74 -8
- package/src/commands/add-project.ts +1 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/fork.ts +1 -2
- package/src/commands/model.ts +24 -4
- package/src/commands/session.ts +1 -3
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +61 -0
- package/src/discord-bot.ts +36 -33
- package/src/discord-utils.ts +76 -14
- package/src/interaction-handler.ts +25 -0
- package/src/logger.ts +47 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +132 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +93 -25
- package/src/opencode.ts +80 -21
- package/src/session-handler.ts +180 -90
- package/src/system-message.ts +4 -51
- package/src/voice-handler.ts +20 -9
- package/src/voice.ts +32 -13
- package/LICENSE +0 -21
package/dist/commands/model.js
CHANGED
|
@@ -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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
package/dist/commands/session.js
CHANGED
|
@@ -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
|
|
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();
|
package/dist/discord-bot.js
CHANGED
|
@@ -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 {
|
|
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
|
|
157
|
-
|
|
157
|
+
let currentSessionContext;
|
|
158
|
+
let lastSessionContext;
|
|
159
|
+
if (projectDirectory) {
|
|
158
160
|
try {
|
|
159
161
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
160
|
-
const
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/dist/discord-utils.js
CHANGED
|
@@ -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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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) =>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
}
|