kimaki 0.4.18 → 0.4.20
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/README.md +0 -21
- package/dist/cli.js +46 -154
- package/dist/discordBot.js +308 -107
- package/dist/genai.js +1 -1
- package/dist/xai-realtime.js +95 -0
- package/package.json +7 -7
- package/src/cli.ts +52 -216
- package/src/discordBot.ts +369 -114
- package/src/genai-worker.ts +1 -1
- package/src/genai.ts +1 -1
- package/src/opencode-command-send-to-discord.md +0 -12
package/dist/discordBot.js
CHANGED
|
@@ -39,6 +39,8 @@ export function getOpencodeSystemMessage({ sessionId }) {
|
|
|
39
39
|
return `
|
|
40
40
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
41
41
|
|
|
42
|
+
The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
|
|
43
|
+
|
|
42
44
|
Your current OpenCode session ID is: ${sessionId}
|
|
43
45
|
|
|
44
46
|
## uploading files to discord
|
|
@@ -458,6 +460,7 @@ export function getDatabase() {
|
|
|
458
460
|
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
459
461
|
app_id TEXT PRIMARY KEY,
|
|
460
462
|
gemini_api_key TEXT,
|
|
463
|
+
xai_api_key TEXT,
|
|
461
464
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
462
465
|
)
|
|
463
466
|
`);
|
|
@@ -642,11 +645,50 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
|
|
|
642
645
|
await sendThreadMessage(thread, `📝 **Transcribed message:** ${escapeDiscordFormatting(transcription)}`);
|
|
643
646
|
return transcription;
|
|
644
647
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
+
const TEXT_MIME_TYPES = [
|
|
649
|
+
'text/',
|
|
650
|
+
'application/json',
|
|
651
|
+
'application/xml',
|
|
652
|
+
'application/javascript',
|
|
653
|
+
'application/typescript',
|
|
654
|
+
'application/x-yaml',
|
|
655
|
+
'application/toml',
|
|
656
|
+
];
|
|
657
|
+
function isTextMimeType(contentType) {
|
|
658
|
+
if (!contentType) {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix));
|
|
662
|
+
}
|
|
663
|
+
async function getTextAttachments(message) {
|
|
664
|
+
const textAttachments = Array.from(message.attachments.values()).filter((attachment) => isTextMimeType(attachment.contentType));
|
|
665
|
+
if (textAttachments.length === 0) {
|
|
666
|
+
return '';
|
|
667
|
+
}
|
|
668
|
+
const textContents = await Promise.all(textAttachments.map(async (attachment) => {
|
|
669
|
+
try {
|
|
670
|
+
const response = await fetch(attachment.url);
|
|
671
|
+
if (!response.ok) {
|
|
672
|
+
return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
|
|
673
|
+
}
|
|
674
|
+
const text = await response.text();
|
|
675
|
+
return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
679
|
+
return `<attachment filename="${attachment.name}" error="${errMsg}" />`;
|
|
680
|
+
}
|
|
681
|
+
}));
|
|
682
|
+
return textContents.join('\n\n');
|
|
683
|
+
}
|
|
684
|
+
function getFileAttachments(message) {
|
|
685
|
+
const fileAttachments = Array.from(message.attachments.values()).filter((attachment) => {
|
|
686
|
+
const contentType = attachment.contentType || '';
|
|
687
|
+
return (contentType.startsWith('image/') || contentType === 'application/pdf');
|
|
688
|
+
});
|
|
689
|
+
return fileAttachments.map((attachment) => ({
|
|
648
690
|
type: 'file',
|
|
649
|
-
mime: attachment.contentType || '
|
|
691
|
+
mime: attachment.contentType || 'application/octet-stream',
|
|
650
692
|
filename: attachment.name,
|
|
651
693
|
url: attachment.url,
|
|
652
694
|
}));
|
|
@@ -897,13 +939,6 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
897
939
|
function getToolSummaryText(part) {
|
|
898
940
|
if (part.type !== 'tool')
|
|
899
941
|
return '';
|
|
900
|
-
if (part.state.status !== 'completed' && part.state.status !== 'error')
|
|
901
|
-
return '';
|
|
902
|
-
if (part.tool === 'bash') {
|
|
903
|
-
const output = part.state.status === 'completed' ? part.state.output : part.state.error;
|
|
904
|
-
const lines = (output || '').split('\n').filter((l) => l.trim());
|
|
905
|
-
return `(${lines.length} line${lines.length === 1 ? '' : 's'})`;
|
|
906
|
-
}
|
|
907
942
|
if (part.tool === 'edit') {
|
|
908
943
|
const newString = part.state.input?.newString || '';
|
|
909
944
|
const oldString = part.state.input?.oldString || '';
|
|
@@ -921,7 +956,8 @@ function getToolSummaryText(part) {
|
|
|
921
956
|
const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
|
|
922
957
|
return urlWithoutProtocol ? `(${urlWithoutProtocol})` : '';
|
|
923
958
|
}
|
|
924
|
-
if (part.tool === '
|
|
959
|
+
if (part.tool === 'bash' ||
|
|
960
|
+
part.tool === 'read' ||
|
|
925
961
|
part.tool === 'list' ||
|
|
926
962
|
part.tool === 'glob' ||
|
|
927
963
|
part.tool === 'grep' ||
|
|
@@ -937,7 +973,7 @@ function getToolSummaryText(part) {
|
|
|
937
973
|
if (value === null || value === undefined)
|
|
938
974
|
return null;
|
|
939
975
|
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
940
|
-
const truncatedValue = stringValue.length >
|
|
976
|
+
const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue;
|
|
941
977
|
return `${key}: ${truncatedValue}`;
|
|
942
978
|
})
|
|
943
979
|
.filter(Boolean);
|
|
@@ -945,16 +981,6 @@ function getToolSummaryText(part) {
|
|
|
945
981
|
return '';
|
|
946
982
|
return `(${inputFields.join(', ')})`;
|
|
947
983
|
}
|
|
948
|
-
function getToolOutputToDisplay(part) {
|
|
949
|
-
if (part.type !== 'tool')
|
|
950
|
-
return '';
|
|
951
|
-
if (part.state.status !== 'completed' && part.state.status !== 'error')
|
|
952
|
-
return '';
|
|
953
|
-
if (part.state.status === 'error') {
|
|
954
|
-
return part.state.error || 'Unknown error';
|
|
955
|
-
}
|
|
956
|
-
return '';
|
|
957
|
-
}
|
|
958
984
|
function formatTodoList(part) {
|
|
959
985
|
if (part.type !== 'tool' || part.tool !== 'todowrite')
|
|
960
986
|
return '';
|
|
@@ -999,35 +1025,36 @@ function formatPart(part) {
|
|
|
999
1025
|
if (part.tool === 'todowrite') {
|
|
1000
1026
|
return formatTodoList(part);
|
|
1001
1027
|
}
|
|
1002
|
-
if (part.state.status
|
|
1028
|
+
if (part.state.status === 'pending') {
|
|
1003
1029
|
return '';
|
|
1004
1030
|
}
|
|
1005
1031
|
const summaryText = getToolSummaryText(part);
|
|
1006
|
-
const
|
|
1032
|
+
const stateTitle = 'title' in part.state ? part.state.title : undefined;
|
|
1007
1033
|
let toolTitle = '';
|
|
1008
1034
|
if (part.state.status === 'error') {
|
|
1009
|
-
toolTitle = 'error';
|
|
1035
|
+
toolTitle = part.state.error || 'error';
|
|
1010
1036
|
}
|
|
1011
1037
|
else if (part.tool === 'bash') {
|
|
1012
1038
|
const command = part.state.input?.command || '';
|
|
1013
1039
|
const isSingleLine = !command.includes('\n');
|
|
1014
1040
|
const hasBackticks = command.includes('`');
|
|
1015
1041
|
if (isSingleLine && command.length <= 120 && !hasBackticks) {
|
|
1016
|
-
toolTitle =
|
|
1042
|
+
toolTitle = `_${command}_`;
|
|
1017
1043
|
}
|
|
1018
1044
|
else {
|
|
1019
|
-
toolTitle =
|
|
1045
|
+
toolTitle = stateTitle ? `_${stateTitle}_` : '';
|
|
1020
1046
|
}
|
|
1021
1047
|
}
|
|
1022
|
-
else if (part.
|
|
1023
|
-
|
|
1048
|
+
else if (part.tool === 'edit' || part.tool === 'write') {
|
|
1049
|
+
const filePath = part.state.input?.filePath || '';
|
|
1050
|
+
const fileName = filePath.split('/').pop() || filePath;
|
|
1051
|
+
toolTitle = fileName ? `_${fileName}_` : '';
|
|
1024
1052
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
if (outputToDisplay) {
|
|
1028
|
-
return title + '\n\n' + outputToDisplay;
|
|
1053
|
+
else if (stateTitle) {
|
|
1054
|
+
toolTitle = `_${stateTitle}_`;
|
|
1029
1055
|
}
|
|
1030
|
-
|
|
1056
|
+
const icon = part.state.status === 'error' ? '⨯' : '◼︎';
|
|
1057
|
+
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
1031
1058
|
}
|
|
1032
1059
|
discordLogger.warn('Unknown part type:', part);
|
|
1033
1060
|
return '';
|
|
@@ -1052,16 +1079,6 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1052
1079
|
voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
|
|
1053
1080
|
// Track session start time
|
|
1054
1081
|
const sessionStartTime = Date.now();
|
|
1055
|
-
// Add processing reaction to original message
|
|
1056
|
-
if (originalMessage) {
|
|
1057
|
-
try {
|
|
1058
|
-
await originalMessage.react('⏳');
|
|
1059
|
-
discordLogger.log(`Added processing reaction to message`);
|
|
1060
|
-
}
|
|
1061
|
-
catch (e) {
|
|
1062
|
-
discordLogger.log(`Could not add processing reaction:`, e);
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
1082
|
// Use default directory if not specified
|
|
1066
1083
|
const directory = projectDirectory || process.cwd();
|
|
1067
1084
|
sessionLogger.log(`Using directory: ${directory}`);
|
|
@@ -1090,9 +1107,10 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1090
1107
|
}
|
|
1091
1108
|
}
|
|
1092
1109
|
if (!session) {
|
|
1093
|
-
|
|
1110
|
+
const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
|
|
1111
|
+
voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
|
|
1094
1112
|
const sessionResponse = await getClient().session.create({
|
|
1095
|
-
body: { title:
|
|
1113
|
+
body: { title: sessionTitle },
|
|
1096
1114
|
});
|
|
1097
1115
|
session = sessionResponse.data;
|
|
1098
1116
|
sessionLogger.log(`Created new session ${session?.id}`);
|
|
@@ -1111,39 +1129,37 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1111
1129
|
voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
|
|
1112
1130
|
existingController.abort(new Error('New request started'));
|
|
1113
1131
|
}
|
|
1114
|
-
if (abortControllers.has(session.id)) {
|
|
1115
|
-
abortControllers.get(session.id)?.abort(new Error('new reply'));
|
|
1116
|
-
}
|
|
1117
1132
|
const abortController = new AbortController();
|
|
1118
|
-
// Store this controller for this session
|
|
1119
1133
|
abortControllers.set(session.id, abortController);
|
|
1134
|
+
if (existingController) {
|
|
1135
|
+
await new Promise((resolve) => { setTimeout(resolve, 200); });
|
|
1136
|
+
if (abortController.signal.aborted) {
|
|
1137
|
+
sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (abortController.signal.aborted) {
|
|
1142
|
+
sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1120
1145
|
const eventsResult = await getClient().event.subscribe({
|
|
1121
1146
|
signal: abortController.signal,
|
|
1122
1147
|
});
|
|
1148
|
+
if (abortController.signal.aborted) {
|
|
1149
|
+
sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1123
1152
|
const events = eventsResult.stream;
|
|
1124
1153
|
sessionLogger.log(`Subscribed to OpenCode events`);
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
.
|
|
1129
|
-
.all(thread.id);
|
|
1130
|
-
// Pre-populate map with existing messages
|
|
1131
|
-
for (const row of existingParts) {
|
|
1132
|
-
try {
|
|
1133
|
-
const message = await thread.messages.fetch(row.message_id);
|
|
1134
|
-
if (message) {
|
|
1135
|
-
partIdToMessage.set(row.part_id, message);
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
catch (error) {
|
|
1139
|
-
voiceLogger.log(`Could not fetch message ${row.message_id} for part ${row.part_id}`);
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1154
|
+
const sentPartIds = new Set(getDatabase()
|
|
1155
|
+
.prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
|
|
1156
|
+
.all(thread.id)
|
|
1157
|
+
.map((row) => row.part_id));
|
|
1142
1158
|
let currentParts = [];
|
|
1143
1159
|
let stopTyping = null;
|
|
1144
1160
|
let usedModel;
|
|
1145
1161
|
let usedProviderID;
|
|
1146
|
-
let
|
|
1162
|
+
let tokensUsedInSession = 0;
|
|
1147
1163
|
const sendPartMessage = async (part) => {
|
|
1148
1164
|
const content = formatPart(part) + '\n\n';
|
|
1149
1165
|
if (!content.trim() || content.length === 0) {
|
|
@@ -1151,12 +1167,12 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1151
1167
|
return;
|
|
1152
1168
|
}
|
|
1153
1169
|
// Skip if already sent
|
|
1154
|
-
if (
|
|
1170
|
+
if (sentPartIds.has(part.id)) {
|
|
1155
1171
|
return;
|
|
1156
1172
|
}
|
|
1157
1173
|
try {
|
|
1158
1174
|
const firstMessage = await sendThreadMessage(thread, content);
|
|
1159
|
-
|
|
1175
|
+
sentPartIds.add(part.id);
|
|
1160
1176
|
// Store part-message mapping in database
|
|
1161
1177
|
getDatabase()
|
|
1162
1178
|
.prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
|
|
@@ -1219,12 +1235,13 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1219
1235
|
}
|
|
1220
1236
|
// Track assistant message ID
|
|
1221
1237
|
if (msg.role === 'assistant') {
|
|
1238
|
+
const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write;
|
|
1239
|
+
if (newTokensTotal > 0) {
|
|
1240
|
+
tokensUsedInSession = newTokensTotal;
|
|
1241
|
+
}
|
|
1222
1242
|
assistantMessageId = msg.id;
|
|
1223
1243
|
usedModel = msg.modelID;
|
|
1224
1244
|
usedProviderID = msg.providerID;
|
|
1225
|
-
if (msg.tokens.input > 0) {
|
|
1226
|
-
inputTokens = msg.tokens.input;
|
|
1227
|
-
}
|
|
1228
1245
|
}
|
|
1229
1246
|
}
|
|
1230
1247
|
else if (event.type === 'message.part.updated') {
|
|
@@ -1247,13 +1264,16 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1247
1264
|
if (part.type === 'step-start') {
|
|
1248
1265
|
stopTyping = startTyping(thread);
|
|
1249
1266
|
}
|
|
1267
|
+
// Send tool parts immediately when they start running
|
|
1268
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
1269
|
+
await sendPartMessage(part);
|
|
1270
|
+
}
|
|
1271
|
+
// Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
|
|
1272
|
+
if (part.type === 'reasoning') {
|
|
1273
|
+
await sendPartMessage(part);
|
|
1274
|
+
}
|
|
1250
1275
|
// Check if this is a step-finish part
|
|
1251
1276
|
if (part.type === 'step-finish') {
|
|
1252
|
-
// Track tokens from step-finish part
|
|
1253
|
-
if (part.tokens?.input && part.tokens.input > 0) {
|
|
1254
|
-
inputTokens = part.tokens.input;
|
|
1255
|
-
voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`);
|
|
1256
|
-
}
|
|
1257
1277
|
// Send all parts accumulated so far to Discord
|
|
1258
1278
|
for (const p of currentParts) {
|
|
1259
1279
|
// Skip step-start and step-finish parts as they have no visual content
|
|
@@ -1338,7 +1358,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1338
1358
|
finally {
|
|
1339
1359
|
// Send any remaining parts that weren't sent
|
|
1340
1360
|
for (const part of currentParts) {
|
|
1341
|
-
if (!
|
|
1361
|
+
if (!sentPartIds.has(part.id)) {
|
|
1342
1362
|
try {
|
|
1343
1363
|
await sendPartMessage(part);
|
|
1344
1364
|
}
|
|
@@ -1359,22 +1379,20 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1359
1379
|
const attachCommand = port ? ` ⋅ ${session.id}` : '';
|
|
1360
1380
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
|
|
1361
1381
|
let contextInfo = '';
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
contextInfo = ` ⋅ ${percentage}%`;
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
catch (e) {
|
|
1373
|
-
sessionLogger.error('Failed to fetch provider info for context percentage:', e);
|
|
1382
|
+
try {
|
|
1383
|
+
const providersResponse = await getClient().provider.list({ query: { directory } });
|
|
1384
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
|
|
1385
|
+
const model = provider?.models?.[usedModel || ''];
|
|
1386
|
+
if (model?.limit?.context) {
|
|
1387
|
+
const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
|
|
1388
|
+
contextInfo = ` ⋅ ${percentage}%`;
|
|
1374
1389
|
}
|
|
1375
1390
|
}
|
|
1391
|
+
catch (e) {
|
|
1392
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', e);
|
|
1393
|
+
}
|
|
1376
1394
|
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`);
|
|
1377
|
-
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${
|
|
1395
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
|
|
1378
1396
|
}
|
|
1379
1397
|
else {
|
|
1380
1398
|
sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
|
|
@@ -1382,8 +1400,19 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1382
1400
|
}
|
|
1383
1401
|
};
|
|
1384
1402
|
try {
|
|
1385
|
-
// Start the event handler
|
|
1386
1403
|
const eventHandlerPromise = eventHandler();
|
|
1404
|
+
if (abortController.signal.aborted) {
|
|
1405
|
+
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (originalMessage) {
|
|
1409
|
+
try {
|
|
1410
|
+
await originalMessage.react('⏳');
|
|
1411
|
+
}
|
|
1412
|
+
catch (e) {
|
|
1413
|
+
discordLogger.log(`Could not add processing reaction:`, e);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1387
1416
|
let response;
|
|
1388
1417
|
if (parsedCommand?.isCommand) {
|
|
1389
1418
|
sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
|
|
@@ -1412,17 +1441,30 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1412
1441
|
signal: abortController.signal,
|
|
1413
1442
|
});
|
|
1414
1443
|
}
|
|
1444
|
+
if (response.error) {
|
|
1445
|
+
const errorMessage = (() => {
|
|
1446
|
+
const err = response.error;
|
|
1447
|
+
if (err && typeof err === 'object') {
|
|
1448
|
+
if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
|
|
1449
|
+
return String(err.data.message);
|
|
1450
|
+
}
|
|
1451
|
+
if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
|
|
1452
|
+
return JSON.stringify(err.errors);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return JSON.stringify(err);
|
|
1456
|
+
})();
|
|
1457
|
+
throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
|
|
1458
|
+
}
|
|
1415
1459
|
abortController.abort('finished');
|
|
1416
1460
|
sessionLogger.log(`Successfully sent prompt, got response`);
|
|
1417
|
-
// Update reaction to success
|
|
1418
1461
|
if (originalMessage) {
|
|
1419
1462
|
try {
|
|
1420
1463
|
await originalMessage.reactions.removeAll();
|
|
1421
1464
|
await originalMessage.react('✅');
|
|
1422
|
-
discordLogger.log(`Added success reaction to message`);
|
|
1423
1465
|
}
|
|
1424
1466
|
catch (e) {
|
|
1425
|
-
discordLogger.log(`Could not update
|
|
1467
|
+
discordLogger.log(`Could not update reactions:`, e);
|
|
1426
1468
|
}
|
|
1427
1469
|
}
|
|
1428
1470
|
return { sessionID: session.id, result: response.data, port };
|
|
@@ -1596,14 +1638,18 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1596
1638
|
if (transcription) {
|
|
1597
1639
|
messageContent = transcription;
|
|
1598
1640
|
}
|
|
1599
|
-
const
|
|
1641
|
+
const fileAttachments = getFileAttachments(message);
|
|
1642
|
+
const textAttachmentsContent = await getTextAttachments(message);
|
|
1643
|
+
const promptWithAttachments = textAttachmentsContent
|
|
1644
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
1645
|
+
: messageContent;
|
|
1600
1646
|
const parsedCommand = parseSlashCommand(messageContent);
|
|
1601
1647
|
await handleOpencodeSession({
|
|
1602
|
-
prompt:
|
|
1648
|
+
prompt: promptWithAttachments,
|
|
1603
1649
|
thread,
|
|
1604
1650
|
projectDirectory,
|
|
1605
1651
|
originalMessage: message,
|
|
1606
|
-
images,
|
|
1652
|
+
images: fileAttachments,
|
|
1607
1653
|
parsedCommand,
|
|
1608
1654
|
});
|
|
1609
1655
|
return;
|
|
@@ -1664,14 +1710,18 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1664
1710
|
if (transcription) {
|
|
1665
1711
|
messageContent = transcription;
|
|
1666
1712
|
}
|
|
1667
|
-
const
|
|
1713
|
+
const fileAttachments = getFileAttachments(message);
|
|
1714
|
+
const textAttachmentsContent = await getTextAttachments(message);
|
|
1715
|
+
const promptWithAttachments = textAttachmentsContent
|
|
1716
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
1717
|
+
: messageContent;
|
|
1668
1718
|
const parsedCommand = parseSlashCommand(messageContent);
|
|
1669
1719
|
await handleOpencodeSession({
|
|
1670
|
-
prompt:
|
|
1720
|
+
prompt: promptWithAttachments,
|
|
1671
1721
|
thread,
|
|
1672
1722
|
projectDirectory,
|
|
1673
1723
|
originalMessage: message,
|
|
1674
|
-
images,
|
|
1724
|
+
images: fileAttachments,
|
|
1675
1725
|
parsedCommand,
|
|
1676
1726
|
});
|
|
1677
1727
|
}
|
|
@@ -2093,6 +2143,72 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2093
2143
|
await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
2094
2144
|
}
|
|
2095
2145
|
}
|
|
2146
|
+
else if (command.commandName === 'add-new-project') {
|
|
2147
|
+
await command.deferReply({ ephemeral: false });
|
|
2148
|
+
const projectName = command.options.getString('name', true);
|
|
2149
|
+
const guild = command.guild;
|
|
2150
|
+
const channel = command.channel;
|
|
2151
|
+
if (!guild) {
|
|
2152
|
+
await command.editReply('This command can only be used in a guild');
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
2156
|
+
await command.editReply('This command can only be used in a text channel');
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
const sanitizedName = projectName
|
|
2160
|
+
.toLowerCase()
|
|
2161
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
2162
|
+
.replace(/-+/g, '-')
|
|
2163
|
+
.replace(/^-|-$/g, '')
|
|
2164
|
+
.slice(0, 100);
|
|
2165
|
+
if (!sanitizedName) {
|
|
2166
|
+
await command.editReply('Invalid project name');
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
const kimakiDir = path.join(os.homedir(), 'kimaki');
|
|
2170
|
+
const projectDirectory = path.join(kimakiDir, sanitizedName);
|
|
2171
|
+
try {
|
|
2172
|
+
if (!fs.existsSync(kimakiDir)) {
|
|
2173
|
+
fs.mkdirSync(kimakiDir, { recursive: true });
|
|
2174
|
+
discordLogger.log(`Created kimaki directory: ${kimakiDir}`);
|
|
2175
|
+
}
|
|
2176
|
+
if (fs.existsSync(projectDirectory)) {
|
|
2177
|
+
await command.editReply(`Project directory already exists: ${projectDirectory}`);
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
2181
|
+
discordLogger.log(`Created project directory: ${projectDirectory}`);
|
|
2182
|
+
const { execSync } = await import('node:child_process');
|
|
2183
|
+
execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
|
|
2184
|
+
discordLogger.log(`Initialized git in: ${projectDirectory}`);
|
|
2185
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
2186
|
+
guild,
|
|
2187
|
+
projectDirectory,
|
|
2188
|
+
appId: currentAppId,
|
|
2189
|
+
});
|
|
2190
|
+
const textChannel = await guild.channels.fetch(textChannelId);
|
|
2191
|
+
await command.editReply(`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`);
|
|
2192
|
+
const starterMessage = await textChannel.send({
|
|
2193
|
+
content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
|
|
2194
|
+
});
|
|
2195
|
+
const thread = await starterMessage.startThread({
|
|
2196
|
+
name: `Init: ${sanitizedName}`,
|
|
2197
|
+
autoArchiveDuration: 1440,
|
|
2198
|
+
reason: 'New project session',
|
|
2199
|
+
});
|
|
2200
|
+
await handleOpencodeSession({
|
|
2201
|
+
prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
|
|
2202
|
+
thread,
|
|
2203
|
+
projectDirectory,
|
|
2204
|
+
});
|
|
2205
|
+
discordLogger.log(`Created new project ${channelName} at ${projectDirectory}`);
|
|
2206
|
+
}
|
|
2207
|
+
catch (error) {
|
|
2208
|
+
voiceLogger.error('[ADD-NEW-PROJECT] Error:', error);
|
|
2209
|
+
await command.editReply(`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2096
2212
|
else if (command.commandName === 'accept' ||
|
|
2097
2213
|
command.commandName === 'accept-always') {
|
|
2098
2214
|
const scope = command.commandName === 'accept-always' ? 'always' : 'once';
|
|
@@ -2264,6 +2380,70 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2264
2380
|
});
|
|
2265
2381
|
}
|
|
2266
2382
|
}
|
|
2383
|
+
else if (command.commandName === 'share') {
|
|
2384
|
+
const channel = command.channel;
|
|
2385
|
+
if (!channel) {
|
|
2386
|
+
await command.reply({
|
|
2387
|
+
content: 'This command can only be used in a channel',
|
|
2388
|
+
ephemeral: true,
|
|
2389
|
+
});
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
const isThread = [
|
|
2393
|
+
ChannelType.PublicThread,
|
|
2394
|
+
ChannelType.PrivateThread,
|
|
2395
|
+
ChannelType.AnnouncementThread,
|
|
2396
|
+
].includes(channel.type);
|
|
2397
|
+
if (!isThread) {
|
|
2398
|
+
await command.reply({
|
|
2399
|
+
content: 'This command can only be used in a thread with an active session',
|
|
2400
|
+
ephemeral: true,
|
|
2401
|
+
});
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
const textChannel = resolveTextChannel(channel);
|
|
2405
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
2406
|
+
if (!directory) {
|
|
2407
|
+
await command.reply({
|
|
2408
|
+
content: 'Could not determine project directory for this channel',
|
|
2409
|
+
ephemeral: true,
|
|
2410
|
+
});
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
const row = getDatabase()
|
|
2414
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
2415
|
+
.get(channel.id);
|
|
2416
|
+
if (!row?.session_id) {
|
|
2417
|
+
await command.reply({
|
|
2418
|
+
content: 'No active session in this thread',
|
|
2419
|
+
ephemeral: true,
|
|
2420
|
+
});
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
const sessionId = row.session_id;
|
|
2424
|
+
try {
|
|
2425
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
2426
|
+
const response = await getClient().session.share({
|
|
2427
|
+
path: { id: sessionId },
|
|
2428
|
+
});
|
|
2429
|
+
if (!response.data?.share?.url) {
|
|
2430
|
+
await command.reply({
|
|
2431
|
+
content: 'Failed to generate share URL',
|
|
2432
|
+
ephemeral: true,
|
|
2433
|
+
});
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
await command.reply(`🔗 **Session shared:** ${response.data.share.url}`);
|
|
2437
|
+
sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`);
|
|
2438
|
+
}
|
|
2439
|
+
catch (error) {
|
|
2440
|
+
voiceLogger.error('[SHARE] Error:', error);
|
|
2441
|
+
await command.reply({
|
|
2442
|
+
content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2443
|
+
ephemeral: true,
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2267
2447
|
}
|
|
2268
2448
|
}
|
|
2269
2449
|
catch (error) {
|
|
@@ -2479,7 +2659,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2479
2659
|
}
|
|
2480
2660
|
});
|
|
2481
2661
|
await discordClient.login(token);
|
|
2482
|
-
const handleShutdown = async (signal) => {
|
|
2662
|
+
const handleShutdown = async (signal, { skipExit = false } = {}) => {
|
|
2483
2663
|
discordLogger.log(`Received ${signal}, cleaning up...`);
|
|
2484
2664
|
// Prevent multiple shutdown calls
|
|
2485
2665
|
if (global.shuttingDown) {
|
|
@@ -2516,12 +2696,16 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2516
2696
|
}
|
|
2517
2697
|
discordLogger.log('Destroying Discord client...');
|
|
2518
2698
|
discordClient.destroy();
|
|
2519
|
-
discordLogger.log('Cleanup complete
|
|
2520
|
-
|
|
2699
|
+
discordLogger.log('Cleanup complete.');
|
|
2700
|
+
if (!skipExit) {
|
|
2701
|
+
process.exit(0);
|
|
2702
|
+
}
|
|
2521
2703
|
}
|
|
2522
2704
|
catch (error) {
|
|
2523
2705
|
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
|
|
2524
|
-
|
|
2706
|
+
if (!skipExit) {
|
|
2707
|
+
process.exit(1);
|
|
2708
|
+
}
|
|
2525
2709
|
}
|
|
2526
2710
|
};
|
|
2527
2711
|
// Override default signal handlers to prevent immediate exit
|
|
@@ -2543,6 +2727,23 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2543
2727
|
process.exit(1);
|
|
2544
2728
|
}
|
|
2545
2729
|
});
|
|
2730
|
+
process.on('SIGUSR2', async () => {
|
|
2731
|
+
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|
|
2732
|
+
try {
|
|
2733
|
+
await handleShutdown('SIGUSR2', { skipExit: true });
|
|
2734
|
+
}
|
|
2735
|
+
catch (error) {
|
|
2736
|
+
voiceLogger.error('[SIGUSR2] Error during shutdown:', error);
|
|
2737
|
+
}
|
|
2738
|
+
const { spawn } = await import('node:child_process');
|
|
2739
|
+
spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
|
|
2740
|
+
stdio: 'inherit',
|
|
2741
|
+
detached: true,
|
|
2742
|
+
cwd: process.cwd(),
|
|
2743
|
+
env: process.env,
|
|
2744
|
+
}).unref();
|
|
2745
|
+
process.exit(0);
|
|
2746
|
+
});
|
|
2546
2747
|
// Prevent unhandled promise rejections from crashing the process during shutdown
|
|
2547
2748
|
process.on('unhandledRejection', (reason, promise) => {
|
|
2548
2749
|
if (global.shuttingDown) {
|
package/dist/genai.js
CHANGED
|
@@ -169,7 +169,7 @@ export async function startGenAiSession({ onAssistantAudioChunk, onAssistantStar
|
|
|
169
169
|
const ai = new GoogleGenAI({
|
|
170
170
|
apiKey,
|
|
171
171
|
});
|
|
172
|
-
const model = '
|
|
172
|
+
const model = 'gemini-2.5-flash-native-audio-preview-12-2025';
|
|
173
173
|
session = await ai.live.connect({
|
|
174
174
|
model,
|
|
175
175
|
callbacks: {
|