kimaki 0.4.12 → 0.4.14
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/cli.js +81 -12
- package/dist/discordBot.js +225 -114
- package/dist/tools.js +3 -3
- package/package.json +11 -12
- package/src/cli.ts +108 -11
- package/src/discordBot.ts +268 -164
- package/src/opencode-command-send-to-discord.md +12 -0
- package/src/opencode-command-upload-to-discord.md +22 -0
- package/src/tools.ts +3 -3
- package/src/opencode-command.md +0 -4
- package/src/opencode-plugin.ts +0 -75
package/dist/discordBot.js
CHANGED
|
@@ -22,9 +22,25 @@ import { isAbortError } from './utils.js';
|
|
|
22
22
|
import { setGlobalDispatcher, Agent } from 'undici';
|
|
23
23
|
// disables the automatic 5 minutes abort after no body
|
|
24
24
|
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }));
|
|
25
|
-
|
|
25
|
+
function parseSlashCommand(text) {
|
|
26
|
+
const trimmed = text.trim();
|
|
27
|
+
if (!trimmed.startsWith('/')) {
|
|
28
|
+
return { isCommand: false };
|
|
29
|
+
}
|
|
30
|
+
const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/);
|
|
31
|
+
if (!match) {
|
|
32
|
+
return { isCommand: false };
|
|
33
|
+
}
|
|
34
|
+
const command = match[1];
|
|
35
|
+
const args = match[2]?.trim() || '';
|
|
36
|
+
return { isCommand: true, command, arguments: args };
|
|
37
|
+
}
|
|
38
|
+
export function getOpencodeSystemMessage({ sessionId }) {
|
|
39
|
+
return `
|
|
26
40
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
27
41
|
|
|
42
|
+
Your current OpenCode session ID is: ${sessionId}
|
|
43
|
+
|
|
28
44
|
After each message, if you implemented changes, you can show the user a diff via an url running the command, to show the changes in working directory:
|
|
29
45
|
|
|
30
46
|
bunx critique web
|
|
@@ -66,6 +82,7 @@ code blocks for tables and diagrams MUST have Max length of 85 characters. other
|
|
|
66
82
|
|
|
67
83
|
you can create diagrams wrapping them in code blocks too.
|
|
68
84
|
`;
|
|
85
|
+
}
|
|
69
86
|
const discordLogger = createLogger('DISCORD');
|
|
70
87
|
const voiceLogger = createLogger('VOICE');
|
|
71
88
|
const opencodeLogger = createLogger('OPENCODE');
|
|
@@ -129,7 +146,7 @@ async function createUserAudioLogStream(guildId, channelId) {
|
|
|
129
146
|
}
|
|
130
147
|
}
|
|
131
148
|
// Set up voice handling for a connection (called once per connection)
|
|
132
|
-
async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
|
|
149
|
+
async function setupVoiceHandling({ connection, guildId, channelId, appId, discordClient, }) {
|
|
133
150
|
voiceLogger.log(`Setting up voice handling for guild ${guildId}, channel ${channelId}`);
|
|
134
151
|
// Check if this voice channel has an associated directory
|
|
135
152
|
const channelDirRow = getDatabase()
|
|
@@ -227,8 +244,24 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
|
|
|
227
244
|
: `<systemMessage>\nThe coding agent finished working on session ${params.sessionId}\n\nHere's what the assistant wrote:\n${params.markdown}\n</systemMessage>`;
|
|
228
245
|
genAiWorker.sendTextInput(text);
|
|
229
246
|
},
|
|
230
|
-
onError(error) {
|
|
247
|
+
async onError(error) {
|
|
231
248
|
voiceLogger.error('GenAI worker error:', error);
|
|
249
|
+
const textChannelRow = getDatabase()
|
|
250
|
+
.prepare(`SELECT cd2.channel_id FROM channel_directories cd1
|
|
251
|
+
JOIN channel_directories cd2 ON cd1.directory = cd2.directory
|
|
252
|
+
WHERE cd1.channel_id = ? AND cd1.channel_type = 'voice' AND cd2.channel_type = 'text'`)
|
|
253
|
+
.get(channelId);
|
|
254
|
+
if (textChannelRow) {
|
|
255
|
+
try {
|
|
256
|
+
const textChannel = await discordClient.channels.fetch(textChannelRow.channel_id);
|
|
257
|
+
if (textChannel?.isTextBased() && 'send' in textChannel) {
|
|
258
|
+
await textChannel.send(`⚠️ Voice session error: ${error}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
voiceLogger.error('Failed to send error to text channel:', e);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
232
265
|
},
|
|
233
266
|
});
|
|
234
267
|
// Stop any existing GenAI worker before storing new one
|
|
@@ -912,24 +945,27 @@ function getToolOutputToDisplay(part) {
|
|
|
912
945
|
if (part.state.status === 'error') {
|
|
913
946
|
return part.state.error || 'Unknown error';
|
|
914
947
|
}
|
|
915
|
-
if (part.tool === 'todowrite') {
|
|
916
|
-
const todos = part.state.input?.todos || [];
|
|
917
|
-
return todos
|
|
918
|
-
.map((todo) => {
|
|
919
|
-
let statusIcon = '▢';
|
|
920
|
-
if (todo.status === 'in_progress') {
|
|
921
|
-
statusIcon = '●';
|
|
922
|
-
}
|
|
923
|
-
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
924
|
-
statusIcon = '■';
|
|
925
|
-
}
|
|
926
|
-
return `\`${statusIcon}\` ${todo.content}`;
|
|
927
|
-
})
|
|
928
|
-
.filter(Boolean)
|
|
929
|
-
.join('\n');
|
|
930
|
-
}
|
|
931
948
|
return '';
|
|
932
949
|
}
|
|
950
|
+
function formatTodoList(part) {
|
|
951
|
+
if (part.type !== 'tool' || part.tool !== 'todowrite')
|
|
952
|
+
return '';
|
|
953
|
+
const todos = part.state.input?.todos || [];
|
|
954
|
+
if (todos.length === 0)
|
|
955
|
+
return '';
|
|
956
|
+
return todos
|
|
957
|
+
.map((todo, i) => {
|
|
958
|
+
const num = `${i + 1}.`;
|
|
959
|
+
if (todo.status === 'in_progress') {
|
|
960
|
+
return `${num} **${todo.content}**`;
|
|
961
|
+
}
|
|
962
|
+
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
963
|
+
return `${num} ~~${todo.content}~~`;
|
|
964
|
+
}
|
|
965
|
+
return `${num} ${todo.content}`;
|
|
966
|
+
})
|
|
967
|
+
.join('\n');
|
|
968
|
+
}
|
|
933
969
|
function formatPart(part) {
|
|
934
970
|
if (part.type === 'text') {
|
|
935
971
|
return part.text || '';
|
|
@@ -952,14 +988,31 @@ function formatPart(part) {
|
|
|
952
988
|
return `◼︎ snapshot ${part.snapshot}`;
|
|
953
989
|
}
|
|
954
990
|
if (part.type === 'tool') {
|
|
991
|
+
if (part.tool === 'todowrite') {
|
|
992
|
+
return formatTodoList(part);
|
|
993
|
+
}
|
|
955
994
|
if (part.state.status !== 'completed' && part.state.status !== 'error') {
|
|
956
995
|
return '';
|
|
957
996
|
}
|
|
958
997
|
const summaryText = getToolSummaryText(part);
|
|
959
998
|
const outputToDisplay = getToolOutputToDisplay(part);
|
|
960
|
-
let toolTitle =
|
|
961
|
-
if (
|
|
962
|
-
toolTitle =
|
|
999
|
+
let toolTitle = '';
|
|
1000
|
+
if (part.state.status === 'error') {
|
|
1001
|
+
toolTitle = 'error';
|
|
1002
|
+
}
|
|
1003
|
+
else if (part.tool === 'bash') {
|
|
1004
|
+
const command = part.state.input?.command || '';
|
|
1005
|
+
const isSingleLine = !command.includes('\n');
|
|
1006
|
+
const hasBackticks = command.includes('`');
|
|
1007
|
+
if (isSingleLine && command.length <= 120 && !hasBackticks) {
|
|
1008
|
+
toolTitle = `\`${command}\``;
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
toolTitle = part.state.title ? `*${part.state.title}*` : '';
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
else if (part.state.title) {
|
|
1015
|
+
toolTitle = `*${part.state.title}*`;
|
|
963
1016
|
}
|
|
964
1017
|
const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : '';
|
|
965
1018
|
const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
@@ -987,7 +1040,7 @@ export async function createDiscordClient() {
|
|
|
987
1040
|
],
|
|
988
1041
|
});
|
|
989
1042
|
}
|
|
990
|
-
async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], }) {
|
|
1043
|
+
async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], parsedCommand, }) {
|
|
991
1044
|
voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
|
|
992
1045
|
// Track session start time
|
|
993
1046
|
const sessionStartTime = Date.now();
|
|
@@ -1081,6 +1134,8 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1081
1134
|
let currentParts = [];
|
|
1082
1135
|
let stopTyping = null;
|
|
1083
1136
|
let usedModel;
|
|
1137
|
+
let usedProviderID;
|
|
1138
|
+
let inputTokens = 0;
|
|
1084
1139
|
const sendPartMessage = async (part) => {
|
|
1085
1140
|
const content = formatPart(part) + '\n\n';
|
|
1086
1141
|
if (!content.trim() || content.length === 0) {
|
|
@@ -1089,14 +1144,11 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1089
1144
|
}
|
|
1090
1145
|
// Skip if already sent
|
|
1091
1146
|
if (partIdToMessage.has(part.id)) {
|
|
1092
|
-
voiceLogger.log(`[SEND SKIP] Part ${part.id} already sent as message ${partIdToMessage.get(part.id)?.id}`);
|
|
1093
1147
|
return;
|
|
1094
1148
|
}
|
|
1095
1149
|
try {
|
|
1096
|
-
voiceLogger.log(`[SEND] Sending part ${part.id} (type: ${part.type}) to Discord, content length: ${content.length}`);
|
|
1097
1150
|
const firstMessage = await sendThreadMessage(thread, content);
|
|
1098
1151
|
partIdToMessage.set(part.id, firstMessage);
|
|
1099
|
-
voiceLogger.log(`[SEND SUCCESS] Part ${part.id} sent as message ${firstMessage.id}`);
|
|
1100
1152
|
// Store part-message mapping in database
|
|
1101
1153
|
getDatabase()
|
|
1102
1154
|
.prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
|
|
@@ -1115,12 +1167,10 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1115
1167
|
discordLogger.log(`Not starting typing, already aborted`);
|
|
1116
1168
|
return () => { };
|
|
1117
1169
|
}
|
|
1118
|
-
discordLogger.log(`Starting typing for thread ${thread.id}`);
|
|
1119
1170
|
// Clear any previous typing interval
|
|
1120
1171
|
if (typingInterval) {
|
|
1121
1172
|
clearInterval(typingInterval);
|
|
1122
1173
|
typingInterval = null;
|
|
1123
|
-
discordLogger.log(`Cleared previous typing interval`);
|
|
1124
1174
|
}
|
|
1125
1175
|
// Send initial typing
|
|
1126
1176
|
thread.sendTyping().catch((e) => {
|
|
@@ -1148,39 +1198,34 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1148
1198
|
if (typingInterval) {
|
|
1149
1199
|
clearInterval(typingInterval);
|
|
1150
1200
|
typingInterval = null;
|
|
1151
|
-
discordLogger.log(`Stopped typing for thread ${thread.id}`);
|
|
1152
1201
|
}
|
|
1153
1202
|
};
|
|
1154
1203
|
}
|
|
1155
1204
|
try {
|
|
1156
1205
|
let assistantMessageId;
|
|
1157
1206
|
for await (const event of events) {
|
|
1158
|
-
sessionLogger.log(`Received: ${event.type}`);
|
|
1159
1207
|
if (event.type === 'message.updated') {
|
|
1160
1208
|
const msg = event.properties.info;
|
|
1161
1209
|
if (msg.sessionID !== session.id) {
|
|
1162
|
-
voiceLogger.log(`[EVENT IGNORED] Message from different session (expected: ${session.id}, got: ${msg.sessionID})`);
|
|
1163
1210
|
continue;
|
|
1164
1211
|
}
|
|
1165
1212
|
// Track assistant message ID
|
|
1166
1213
|
if (msg.role === 'assistant') {
|
|
1167
1214
|
assistantMessageId = msg.id;
|
|
1168
1215
|
usedModel = msg.modelID;
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1216
|
+
usedProviderID = msg.providerID;
|
|
1217
|
+
if (msg.tokens.input > 0) {
|
|
1218
|
+
inputTokens = msg.tokens.input;
|
|
1219
|
+
}
|
|
1173
1220
|
}
|
|
1174
1221
|
}
|
|
1175
1222
|
else if (event.type === 'message.part.updated') {
|
|
1176
1223
|
const part = event.properties.part;
|
|
1177
1224
|
if (part.sessionID !== session.id) {
|
|
1178
|
-
voiceLogger.log(`[EVENT IGNORED] Part from different session (expected: ${session.id}, got: ${part.sessionID})`);
|
|
1179
1225
|
continue;
|
|
1180
1226
|
}
|
|
1181
1227
|
// Only process parts from assistant messages
|
|
1182
1228
|
if (part.messageID !== assistantMessageId) {
|
|
1183
|
-
voiceLogger.log(`[EVENT IGNORED] Part from non-assistant message (expected: ${assistantMessageId}, got: ${part.messageID})`);
|
|
1184
1229
|
continue;
|
|
1185
1230
|
}
|
|
1186
1231
|
const existingIndex = currentParts.findIndex((p) => p.id === part.id);
|
|
@@ -1190,15 +1235,18 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1190
1235
|
else {
|
|
1191
1236
|
currentParts.push(part);
|
|
1192
1237
|
}
|
|
1193
|
-
voiceLogger.log(`[PART] Update: id=${part.id}, type=${part.type}, text=${'text' in part && typeof part.text === 'string' ? part.text.slice(0, 50) : ''}`);
|
|
1194
1238
|
// Start typing on step-start
|
|
1195
1239
|
if (part.type === 'step-start') {
|
|
1196
1240
|
stopTyping = startTyping(thread);
|
|
1197
1241
|
}
|
|
1198
1242
|
// Check if this is a step-finish part
|
|
1199
1243
|
if (part.type === 'step-finish') {
|
|
1244
|
+
// Track tokens from step-finish part
|
|
1245
|
+
if (part.tokens?.input && part.tokens.input > 0) {
|
|
1246
|
+
inputTokens = part.tokens.input;
|
|
1247
|
+
voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`);
|
|
1248
|
+
}
|
|
1200
1249
|
// Send all parts accumulated so far to Discord
|
|
1201
|
-
voiceLogger.log(`[STEP-FINISH] Sending ${currentParts.length} parts to Discord`);
|
|
1202
1250
|
for (const p of currentParts) {
|
|
1203
1251
|
// Skip step-start and step-finish parts as they have no visual content
|
|
1204
1252
|
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
@@ -1269,12 +1317,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1269
1317
|
pendingPermissions.delete(thread.id);
|
|
1270
1318
|
}
|
|
1271
1319
|
}
|
|
1272
|
-
else if (event.type === 'file.edited') {
|
|
1273
|
-
sessionLogger.log(`File edited event received`);
|
|
1274
|
-
}
|
|
1275
|
-
else {
|
|
1276
|
-
sessionLogger.log(`Unhandled event type: ${event.type}`);
|
|
1277
|
-
}
|
|
1278
1320
|
}
|
|
1279
1321
|
}
|
|
1280
1322
|
catch (e) {
|
|
@@ -1287,31 +1329,20 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1287
1329
|
}
|
|
1288
1330
|
finally {
|
|
1289
1331
|
// Send any remaining parts that weren't sent
|
|
1290
|
-
voiceLogger.log(`[CLEANUP] Checking ${currentParts.length} parts for unsent messages`);
|
|
1291
|
-
let unsentCount = 0;
|
|
1292
1332
|
for (const part of currentParts) {
|
|
1293
1333
|
if (!partIdToMessage.has(part.id)) {
|
|
1294
|
-
unsentCount++;
|
|
1295
|
-
voiceLogger.log(`[CLEANUP] Sending unsent part: id=${part.id}, type=${part.type}`);
|
|
1296
1334
|
try {
|
|
1297
1335
|
await sendPartMessage(part);
|
|
1298
1336
|
}
|
|
1299
1337
|
catch (error) {
|
|
1300
|
-
sessionLogger.
|
|
1338
|
+
sessionLogger.error(`Failed to send part ${part.id}:`, error);
|
|
1301
1339
|
}
|
|
1302
1340
|
}
|
|
1303
1341
|
}
|
|
1304
|
-
if (unsentCount === 0) {
|
|
1305
|
-
sessionLogger.log(`All parts were already sent`);
|
|
1306
|
-
}
|
|
1307
|
-
else {
|
|
1308
|
-
sessionLogger.log(`Sent ${unsentCount} previously unsent parts`);
|
|
1309
|
-
}
|
|
1310
1342
|
// Stop typing when session ends
|
|
1311
1343
|
if (stopTyping) {
|
|
1312
1344
|
stopTyping();
|
|
1313
1345
|
stopTyping = null;
|
|
1314
|
-
sessionLogger.log(`Stopped typing for session`);
|
|
1315
1346
|
}
|
|
1316
1347
|
// Only send duration message if request was not aborted or was aborted with 'finished' reason
|
|
1317
1348
|
if (!abortController.signal.aborted ||
|
|
@@ -1319,8 +1350,23 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1319
1350
|
const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
|
|
1320
1351
|
const attachCommand = port ? ` ⋅ ${session.id}` : '';
|
|
1321
1352
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
|
|
1322
|
-
|
|
1323
|
-
|
|
1353
|
+
let contextInfo = '';
|
|
1354
|
+
if (inputTokens > 0 && usedProviderID && usedModel) {
|
|
1355
|
+
try {
|
|
1356
|
+
const providersResponse = await getClient().provider.list({ query: { directory } });
|
|
1357
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
|
|
1358
|
+
const model = provider?.models?.[usedModel];
|
|
1359
|
+
if (model?.limit?.context) {
|
|
1360
|
+
const percentage = Math.round((inputTokens / model.limit.context) * 100);
|
|
1361
|
+
contextInfo = ` ⋅ ${percentage}%`;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
catch (e) {
|
|
1365
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', e);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`);
|
|
1369
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${inputTokens}`);
|
|
1324
1370
|
}
|
|
1325
1371
|
else {
|
|
1326
1372
|
sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
|
|
@@ -1328,22 +1374,36 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1328
1374
|
}
|
|
1329
1375
|
};
|
|
1330
1376
|
try {
|
|
1331
|
-
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
1332
|
-
if (images.length > 0) {
|
|
1333
|
-
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
|
|
1334
|
-
}
|
|
1335
1377
|
// Start the event handler
|
|
1336
1378
|
const eventHandlerPromise = eventHandler();
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1379
|
+
let response;
|
|
1380
|
+
if (parsedCommand?.isCommand) {
|
|
1381
|
+
sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
|
|
1382
|
+
response = await getClient().session.command({
|
|
1383
|
+
path: { id: session.id },
|
|
1384
|
+
body: {
|
|
1385
|
+
command: parsedCommand.command,
|
|
1386
|
+
arguments: parsedCommand.arguments,
|
|
1387
|
+
},
|
|
1388
|
+
signal: abortController.signal,
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
1393
|
+
if (images.length > 0) {
|
|
1394
|
+
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
|
|
1395
|
+
}
|
|
1396
|
+
const parts = [{ type: 'text', text: prompt }, ...images];
|
|
1397
|
+
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
|
|
1398
|
+
response = await getClient().session.prompt({
|
|
1399
|
+
path: { id: session.id },
|
|
1400
|
+
body: {
|
|
1401
|
+
parts,
|
|
1402
|
+
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
1403
|
+
},
|
|
1404
|
+
signal: abortController.signal,
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1347
1407
|
abortController.abort('finished');
|
|
1348
1408
|
sessionLogger.log(`Successfully sent prompt, got response`);
|
|
1349
1409
|
// Update reaction to success
|
|
@@ -1457,7 +1517,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1457
1517
|
discordClient.on(Events.MessageCreate, async (message) => {
|
|
1458
1518
|
try {
|
|
1459
1519
|
if (message.author?.bot) {
|
|
1460
|
-
voiceLogger.log(`[IGNORED] Bot message from ${message.author.tag} in channel ${message.channelId}`);
|
|
1461
1520
|
return;
|
|
1462
1521
|
}
|
|
1463
1522
|
if (message.partial) {
|
|
@@ -1475,10 +1534,8 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1475
1534
|
const isOwner = message.member.id === message.guild.ownerId;
|
|
1476
1535
|
const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
|
|
1477
1536
|
if (!isOwner && !isAdmin) {
|
|
1478
|
-
voiceLogger.log(`[IGNORED] Non-authoritative user ${message.author.tag} (ID: ${message.author.id}) - not owner or admin`);
|
|
1479
1537
|
return;
|
|
1480
1538
|
}
|
|
1481
|
-
voiceLogger.log(`[AUTHORIZED] Message from ${message.author.tag} (Owner: ${isOwner}, Admin: ${isAdmin})`);
|
|
1482
1539
|
}
|
|
1483
1540
|
const channel = message.channel;
|
|
1484
1541
|
const isThread = [
|
|
@@ -1532,12 +1589,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1532
1589
|
messageContent = transcription;
|
|
1533
1590
|
}
|
|
1534
1591
|
const images = getImageAttachments(message);
|
|
1592
|
+
const parsedCommand = parseSlashCommand(messageContent);
|
|
1535
1593
|
await handleOpencodeSession({
|
|
1536
1594
|
prompt: messageContent,
|
|
1537
1595
|
thread,
|
|
1538
1596
|
projectDirectory,
|
|
1539
1597
|
originalMessage: message,
|
|
1540
1598
|
images,
|
|
1599
|
+
parsedCommand,
|
|
1541
1600
|
});
|
|
1542
1601
|
return;
|
|
1543
1602
|
}
|
|
@@ -1598,12 +1657,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1598
1657
|
messageContent = transcription;
|
|
1599
1658
|
}
|
|
1600
1659
|
const images = getImageAttachments(message);
|
|
1660
|
+
const parsedCommand = parseSlashCommand(messageContent);
|
|
1601
1661
|
await handleOpencodeSession({
|
|
1602
1662
|
prompt: messageContent,
|
|
1603
1663
|
thread,
|
|
1604
1664
|
projectDirectory,
|
|
1605
1665
|
originalMessage: message,
|
|
1606
1666
|
images,
|
|
1667
|
+
parsedCommand,
|
|
1607
1668
|
});
|
|
1608
1669
|
}
|
|
1609
1670
|
else {
|
|
@@ -1859,10 +1920,12 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1859
1920
|
});
|
|
1860
1921
|
await command.editReply(`Created new session in ${thread.toString()}`);
|
|
1861
1922
|
// Start the OpenCode session
|
|
1923
|
+
const parsedCommand = parseSlashCommand(fullPrompt);
|
|
1862
1924
|
await handleOpencodeSession({
|
|
1863
1925
|
prompt: fullPrompt,
|
|
1864
1926
|
thread,
|
|
1865
1927
|
projectDirectory,
|
|
1928
|
+
parsedCommand,
|
|
1866
1929
|
});
|
|
1867
1930
|
}
|
|
1868
1931
|
catch (error) {
|
|
@@ -1937,52 +2000,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1937
2000
|
await command.editReply(`Resumed session "${sessionTitle}" in ${thread.toString()}`);
|
|
1938
2001
|
// Send initial message to thread
|
|
1939
2002
|
await sendThreadMessage(thread, `📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`);
|
|
1940
|
-
//
|
|
1941
|
-
|
|
2003
|
+
// Collect all assistant parts first, then only render the last 30
|
|
2004
|
+
const allAssistantParts = [];
|
|
1942
2005
|
for (const message of messages) {
|
|
1943
|
-
if (message.info.role === '
|
|
1944
|
-
// Render user messages
|
|
1945
|
-
const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
|
|
1946
|
-
const userTexts = userParts
|
|
1947
|
-
.map((p) => {
|
|
1948
|
-
if (p.type === 'text') {
|
|
1949
|
-
return p.text;
|
|
1950
|
-
}
|
|
1951
|
-
return '';
|
|
1952
|
-
})
|
|
1953
|
-
.filter((t) => t.trim());
|
|
1954
|
-
const userText = userTexts.join('\n\n');
|
|
1955
|
-
if (userText) {
|
|
1956
|
-
// Escape backticks in user messages to prevent formatting issues
|
|
1957
|
-
const escapedText = escapeDiscordFormatting(userText);
|
|
1958
|
-
await sendThreadMessage(thread, `**User:**\n${escapedText}`);
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
else if (message.info.role === 'assistant') {
|
|
1962
|
-
// Render assistant parts
|
|
1963
|
-
const partsToRender = [];
|
|
2006
|
+
if (message.info.role === 'assistant') {
|
|
1964
2007
|
for (const part of message.parts) {
|
|
1965
2008
|
const content = formatPart(part);
|
|
1966
2009
|
if (content.trim()) {
|
|
1967
|
-
|
|
2010
|
+
allAssistantParts.push({ id: part.id, content });
|
|
1968
2011
|
}
|
|
1969
2012
|
}
|
|
1970
|
-
if (partsToRender.length > 0) {
|
|
1971
|
-
const combinedContent = partsToRender
|
|
1972
|
-
.map((p) => p.content)
|
|
1973
|
-
.join('\n\n');
|
|
1974
|
-
const discordMessage = await sendThreadMessage(thread, combinedContent);
|
|
1975
|
-
const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
|
|
1976
|
-
const transaction = getDatabase().transaction((parts) => {
|
|
1977
|
-
for (const part of parts) {
|
|
1978
|
-
stmt.run(part.id, discordMessage.id, thread.id);
|
|
1979
|
-
}
|
|
1980
|
-
});
|
|
1981
|
-
transaction(partsToRender);
|
|
1982
|
-
}
|
|
1983
2013
|
}
|
|
1984
|
-
messageCount++;
|
|
1985
2014
|
}
|
|
2015
|
+
const partsToRender = allAssistantParts.slice(-30);
|
|
2016
|
+
const skippedCount = allAssistantParts.length - partsToRender.length;
|
|
2017
|
+
if (skippedCount > 0) {
|
|
2018
|
+
await sendThreadMessage(thread, `*Skipped ${skippedCount} older assistant parts...*`);
|
|
2019
|
+
}
|
|
2020
|
+
if (partsToRender.length > 0) {
|
|
2021
|
+
const combinedContent = partsToRender
|
|
2022
|
+
.map((p) => p.content)
|
|
2023
|
+
.join('\n\n');
|
|
2024
|
+
const discordMessage = await sendThreadMessage(thread, combinedContent);
|
|
2025
|
+
const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
|
|
2026
|
+
const transaction = getDatabase().transaction((parts) => {
|
|
2027
|
+
for (const part of parts) {
|
|
2028
|
+
stmt.run(part.id, discordMessage.id, thread.id);
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
transaction(partsToRender);
|
|
2032
|
+
}
|
|
2033
|
+
const messageCount = messages.length;
|
|
1986
2034
|
await sendThreadMessage(thread, `✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`);
|
|
1987
2035
|
}
|
|
1988
2036
|
catch (error) {
|
|
@@ -2146,6 +2194,68 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2146
2194
|
});
|
|
2147
2195
|
}
|
|
2148
2196
|
}
|
|
2197
|
+
else if (command.commandName === 'abort') {
|
|
2198
|
+
const channel = command.channel;
|
|
2199
|
+
if (!channel) {
|
|
2200
|
+
await command.reply({
|
|
2201
|
+
content: 'This command can only be used in a channel',
|
|
2202
|
+
ephemeral: true,
|
|
2203
|
+
});
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
const isThread = [
|
|
2207
|
+
ChannelType.PublicThread,
|
|
2208
|
+
ChannelType.PrivateThread,
|
|
2209
|
+
ChannelType.AnnouncementThread,
|
|
2210
|
+
].includes(channel.type);
|
|
2211
|
+
if (!isThread) {
|
|
2212
|
+
await command.reply({
|
|
2213
|
+
content: 'This command can only be used in a thread with an active session',
|
|
2214
|
+
ephemeral: true,
|
|
2215
|
+
});
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
const textChannel = resolveTextChannel(channel);
|
|
2219
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
2220
|
+
if (!directory) {
|
|
2221
|
+
await command.reply({
|
|
2222
|
+
content: 'Could not determine project directory for this channel',
|
|
2223
|
+
ephemeral: true,
|
|
2224
|
+
});
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
const row = getDatabase()
|
|
2228
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
2229
|
+
.get(channel.id);
|
|
2230
|
+
if (!row?.session_id) {
|
|
2231
|
+
await command.reply({
|
|
2232
|
+
content: 'No active session in this thread',
|
|
2233
|
+
ephemeral: true,
|
|
2234
|
+
});
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
const sessionId = row.session_id;
|
|
2238
|
+
try {
|
|
2239
|
+
const existingController = abortControllers.get(sessionId);
|
|
2240
|
+
if (existingController) {
|
|
2241
|
+
existingController.abort(new Error('User requested abort'));
|
|
2242
|
+
abortControllers.delete(sessionId);
|
|
2243
|
+
}
|
|
2244
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
2245
|
+
await getClient().session.abort({
|
|
2246
|
+
path: { id: sessionId },
|
|
2247
|
+
});
|
|
2248
|
+
await command.reply(`🛑 Request **aborted**`);
|
|
2249
|
+
sessionLogger.log(`Session ${sessionId} aborted by user`);
|
|
2250
|
+
}
|
|
2251
|
+
catch (error) {
|
|
2252
|
+
voiceLogger.error('[ABORT] Error:', error);
|
|
2253
|
+
await command.reply({
|
|
2254
|
+
content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2255
|
+
ephemeral: true,
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2149
2259
|
}
|
|
2150
2260
|
}
|
|
2151
2261
|
catch (error) {
|
|
@@ -2321,6 +2431,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2321
2431
|
guildId: newState.guild.id,
|
|
2322
2432
|
channelId: voiceChannel.id,
|
|
2323
2433
|
appId: currentAppId,
|
|
2434
|
+
discordClient,
|
|
2324
2435
|
});
|
|
2325
2436
|
// Handle connection state changes
|
|
2326
2437
|
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
package/dist/tools.js
CHANGED
|
@@ -8,7 +8,7 @@ const toolsLogger = createLogger('TOOLS');
|
|
|
8
8
|
import { formatDistanceToNow } from 'date-fns';
|
|
9
9
|
import { ShareMarkdown } from './markdown.js';
|
|
10
10
|
import pc from 'picocolors';
|
|
11
|
-
import { initializeOpencodeForDirectory,
|
|
11
|
+
import { initializeOpencodeForDirectory, getOpencodeSystemMessage, } from './discordBot.js';
|
|
12
12
|
export async function getTools({ onMessageCompleted, directory, }) {
|
|
13
13
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
14
14
|
const client = getClient();
|
|
@@ -48,7 +48,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
48
48
|
body: {
|
|
49
49
|
parts: [{ type: 'text', text: message }],
|
|
50
50
|
model: sessionModel,
|
|
51
|
-
system:
|
|
51
|
+
system: getOpencodeSystemMessage({ sessionId }),
|
|
52
52
|
},
|
|
53
53
|
})
|
|
54
54
|
.then(async (response) => {
|
|
@@ -115,7 +115,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
|
|
|
115
115
|
path: { id: session.data.id },
|
|
116
116
|
body: {
|
|
117
117
|
parts: [{ type: 'text', text: message }],
|
|
118
|
-
system:
|
|
118
|
+
system: getOpencodeSystemMessage({ sessionId: session.data.id }),
|
|
119
119
|
},
|
|
120
120
|
})
|
|
121
121
|
.then(async (response) => {
|
package/package.json
CHANGED
|
@@ -2,17 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
6
|
-
"scripts": {
|
|
7
|
-
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
|
-
"prepublishOnly": "pnpm tsc",
|
|
9
|
-
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
10
|
-
"watch": "tsx scripts/watch-session.ts",
|
|
11
|
-
"test:events": "tsx test-events.ts",
|
|
12
|
-
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
13
|
-
"test:send": "tsx send-test-message.ts",
|
|
14
|
-
"register-commands": "tsx scripts/register-commands.ts"
|
|
15
|
-
},
|
|
5
|
+
"version": "0.4.14",
|
|
16
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
17
7
|
"bin": "bin.js",
|
|
18
8
|
"files": [
|
|
@@ -54,5 +44,14 @@
|
|
|
54
44
|
"string-dedent": "^3.0.2",
|
|
55
45
|
"undici": "^7.16.0",
|
|
56
46
|
"zod": "^4.0.17"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"dev": "tsx --env-file .env src/cli.ts",
|
|
50
|
+
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
51
|
+
"watch": "tsx scripts/watch-session.ts",
|
|
52
|
+
"test:events": "tsx test-events.ts",
|
|
53
|
+
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
54
|
+
"test:send": "tsx send-test-message.ts",
|
|
55
|
+
"register-commands": "tsx scripts/register-commands.ts"
|
|
57
56
|
}
|
|
58
|
-
}
|
|
57
|
+
}
|