kimaki 0.4.17 → 0.4.19
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 +266 -73
- 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 +319 -75
- 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,11 +39,13 @@ 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
|
|
45
47
|
|
|
46
|
-
To upload files (images, screenshots,
|
|
48
|
+
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
47
49
|
|
|
48
50
|
npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
|
|
49
51
|
|
|
@@ -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,14 +1025,14 @@ 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 || '';
|
|
@@ -1016,18 +1042,14 @@ function formatPart(part) {
|
|
|
1016
1042
|
toolTitle = `\`${command}\``;
|
|
1017
1043
|
}
|
|
1018
1044
|
else {
|
|
1019
|
-
toolTitle =
|
|
1045
|
+
toolTitle = stateTitle ? `*${stateTitle}*` : '';
|
|
1020
1046
|
}
|
|
1021
1047
|
}
|
|
1022
|
-
else if (
|
|
1023
|
-
toolTitle = `*${
|
|
1048
|
+
else if (stateTitle) {
|
|
1049
|
+
toolTitle = `*${stateTitle}*`;
|
|
1024
1050
|
}
|
|
1025
|
-
const icon = part.state.status === '
|
|
1026
|
-
|
|
1027
|
-
if (outputToDisplay) {
|
|
1028
|
-
return title + '\n\n' + outputToDisplay;
|
|
1029
|
-
}
|
|
1030
|
-
return title;
|
|
1051
|
+
const icon = part.state.status === 'error' ? '⨯' : '◼︎';
|
|
1052
|
+
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
1031
1053
|
}
|
|
1032
1054
|
discordLogger.warn('Unknown part type:', part);
|
|
1033
1055
|
return '';
|
|
@@ -1090,9 +1112,10 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1090
1112
|
}
|
|
1091
1113
|
}
|
|
1092
1114
|
if (!session) {
|
|
1093
|
-
|
|
1115
|
+
const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
|
|
1116
|
+
voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
|
|
1094
1117
|
const sessionResponse = await getClient().session.create({
|
|
1095
|
-
body: { title:
|
|
1118
|
+
body: { title: sessionTitle },
|
|
1096
1119
|
});
|
|
1097
1120
|
session = sessionResponse.data;
|
|
1098
1121
|
sessionLogger.log(`Created new session ${session?.id}`);
|
|
@@ -1143,7 +1166,7 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1143
1166
|
let stopTyping = null;
|
|
1144
1167
|
let usedModel;
|
|
1145
1168
|
let usedProviderID;
|
|
1146
|
-
let
|
|
1169
|
+
let tokensUsedInSession = 0;
|
|
1147
1170
|
const sendPartMessage = async (part) => {
|
|
1148
1171
|
const content = formatPart(part) + '\n\n';
|
|
1149
1172
|
if (!content.trim() || content.length === 0) {
|
|
@@ -1219,12 +1242,13 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1219
1242
|
}
|
|
1220
1243
|
// Track assistant message ID
|
|
1221
1244
|
if (msg.role === 'assistant') {
|
|
1245
|
+
const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write;
|
|
1246
|
+
if (newTokensTotal > 0) {
|
|
1247
|
+
tokensUsedInSession = newTokensTotal;
|
|
1248
|
+
}
|
|
1222
1249
|
assistantMessageId = msg.id;
|
|
1223
1250
|
usedModel = msg.modelID;
|
|
1224
1251
|
usedProviderID = msg.providerID;
|
|
1225
|
-
if (msg.tokens.input > 0) {
|
|
1226
|
-
inputTokens = msg.tokens.input;
|
|
1227
|
-
}
|
|
1228
1252
|
}
|
|
1229
1253
|
}
|
|
1230
1254
|
else if (event.type === 'message.part.updated') {
|
|
@@ -1247,13 +1271,12 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1247
1271
|
if (part.type === 'step-start') {
|
|
1248
1272
|
stopTyping = startTyping(thread);
|
|
1249
1273
|
}
|
|
1274
|
+
// Send tool parts immediately when they start running
|
|
1275
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
1276
|
+
await sendPartMessage(part);
|
|
1277
|
+
}
|
|
1250
1278
|
// Check if this is a step-finish part
|
|
1251
1279
|
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
1280
|
// Send all parts accumulated so far to Discord
|
|
1258
1281
|
for (const p of currentParts) {
|
|
1259
1282
|
// Skip step-start and step-finish parts as they have no visual content
|
|
@@ -1359,22 +1382,20 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1359
1382
|
const attachCommand = port ? ` ⋅ ${session.id}` : '';
|
|
1360
1383
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
|
|
1361
1384
|
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);
|
|
1385
|
+
try {
|
|
1386
|
+
const providersResponse = await getClient().provider.list({ query: { directory } });
|
|
1387
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
|
|
1388
|
+
const model = provider?.models?.[usedModel || ''];
|
|
1389
|
+
if (model?.limit?.context) {
|
|
1390
|
+
const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
|
|
1391
|
+
contextInfo = ` ⋅ ${percentage}%`;
|
|
1374
1392
|
}
|
|
1375
1393
|
}
|
|
1394
|
+
catch (e) {
|
|
1395
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', e);
|
|
1396
|
+
}
|
|
1376
1397
|
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`);
|
|
1377
|
-
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${
|
|
1398
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
|
|
1378
1399
|
}
|
|
1379
1400
|
else {
|
|
1380
1401
|
sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
|
|
@@ -1412,17 +1433,30 @@ async function handleOpencodeSession({ prompt, thread, projectDirectory, origina
|
|
|
1412
1433
|
signal: abortController.signal,
|
|
1413
1434
|
});
|
|
1414
1435
|
}
|
|
1436
|
+
if (response.error) {
|
|
1437
|
+
const errorMessage = (() => {
|
|
1438
|
+
const err = response.error;
|
|
1439
|
+
if (err && typeof err === 'object') {
|
|
1440
|
+
if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
|
|
1441
|
+
return String(err.data.message);
|
|
1442
|
+
}
|
|
1443
|
+
if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
|
|
1444
|
+
return JSON.stringify(err.errors);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return JSON.stringify(err);
|
|
1448
|
+
})();
|
|
1449
|
+
throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
|
|
1450
|
+
}
|
|
1415
1451
|
abortController.abort('finished');
|
|
1416
1452
|
sessionLogger.log(`Successfully sent prompt, got response`);
|
|
1417
|
-
// Update reaction to success
|
|
1418
1453
|
if (originalMessage) {
|
|
1419
1454
|
try {
|
|
1420
1455
|
await originalMessage.reactions.removeAll();
|
|
1421
1456
|
await originalMessage.react('✅');
|
|
1422
|
-
discordLogger.log(`Added success reaction to message`);
|
|
1423
1457
|
}
|
|
1424
1458
|
catch (e) {
|
|
1425
|
-
discordLogger.log(`Could not update
|
|
1459
|
+
discordLogger.log(`Could not update reactions:`, e);
|
|
1426
1460
|
}
|
|
1427
1461
|
}
|
|
1428
1462
|
return { sessionID: session.id, result: response.data, port };
|
|
@@ -1596,14 +1630,18 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1596
1630
|
if (transcription) {
|
|
1597
1631
|
messageContent = transcription;
|
|
1598
1632
|
}
|
|
1599
|
-
const
|
|
1633
|
+
const fileAttachments = getFileAttachments(message);
|
|
1634
|
+
const textAttachmentsContent = await getTextAttachments(message);
|
|
1635
|
+
const promptWithAttachments = textAttachmentsContent
|
|
1636
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
1637
|
+
: messageContent;
|
|
1600
1638
|
const parsedCommand = parseSlashCommand(messageContent);
|
|
1601
1639
|
await handleOpencodeSession({
|
|
1602
|
-
prompt:
|
|
1640
|
+
prompt: promptWithAttachments,
|
|
1603
1641
|
thread,
|
|
1604
1642
|
projectDirectory,
|
|
1605
1643
|
originalMessage: message,
|
|
1606
|
-
images,
|
|
1644
|
+
images: fileAttachments,
|
|
1607
1645
|
parsedCommand,
|
|
1608
1646
|
});
|
|
1609
1647
|
return;
|
|
@@ -1664,14 +1702,18 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1664
1702
|
if (transcription) {
|
|
1665
1703
|
messageContent = transcription;
|
|
1666
1704
|
}
|
|
1667
|
-
const
|
|
1705
|
+
const fileAttachments = getFileAttachments(message);
|
|
1706
|
+
const textAttachmentsContent = await getTextAttachments(message);
|
|
1707
|
+
const promptWithAttachments = textAttachmentsContent
|
|
1708
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
1709
|
+
: messageContent;
|
|
1668
1710
|
const parsedCommand = parseSlashCommand(messageContent);
|
|
1669
1711
|
await handleOpencodeSession({
|
|
1670
|
-
prompt:
|
|
1712
|
+
prompt: promptWithAttachments,
|
|
1671
1713
|
thread,
|
|
1672
1714
|
projectDirectory,
|
|
1673
1715
|
originalMessage: message,
|
|
1674
|
-
images,
|
|
1716
|
+
images: fileAttachments,
|
|
1675
1717
|
parsedCommand,
|
|
1676
1718
|
});
|
|
1677
1719
|
}
|
|
@@ -2093,6 +2135,72 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2093
2135
|
await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
2094
2136
|
}
|
|
2095
2137
|
}
|
|
2138
|
+
else if (command.commandName === 'add-new-project') {
|
|
2139
|
+
await command.deferReply({ ephemeral: false });
|
|
2140
|
+
const projectName = command.options.getString('name', true);
|
|
2141
|
+
const guild = command.guild;
|
|
2142
|
+
const channel = command.channel;
|
|
2143
|
+
if (!guild) {
|
|
2144
|
+
await command.editReply('This command can only be used in a guild');
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
2148
|
+
await command.editReply('This command can only be used in a text channel');
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
const sanitizedName = projectName
|
|
2152
|
+
.toLowerCase()
|
|
2153
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
2154
|
+
.replace(/-+/g, '-')
|
|
2155
|
+
.replace(/^-|-$/g, '')
|
|
2156
|
+
.slice(0, 100);
|
|
2157
|
+
if (!sanitizedName) {
|
|
2158
|
+
await command.editReply('Invalid project name');
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
const kimakiDir = path.join(os.homedir(), 'kimaki');
|
|
2162
|
+
const projectDirectory = path.join(kimakiDir, sanitizedName);
|
|
2163
|
+
try {
|
|
2164
|
+
if (!fs.existsSync(kimakiDir)) {
|
|
2165
|
+
fs.mkdirSync(kimakiDir, { recursive: true });
|
|
2166
|
+
discordLogger.log(`Created kimaki directory: ${kimakiDir}`);
|
|
2167
|
+
}
|
|
2168
|
+
if (fs.existsSync(projectDirectory)) {
|
|
2169
|
+
await command.editReply(`Project directory already exists: ${projectDirectory}`);
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
2173
|
+
discordLogger.log(`Created project directory: ${projectDirectory}`);
|
|
2174
|
+
const { execSync } = await import('node:child_process');
|
|
2175
|
+
execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
|
|
2176
|
+
discordLogger.log(`Initialized git in: ${projectDirectory}`);
|
|
2177
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
2178
|
+
guild,
|
|
2179
|
+
projectDirectory,
|
|
2180
|
+
appId: currentAppId,
|
|
2181
|
+
});
|
|
2182
|
+
const textChannel = await guild.channels.fetch(textChannelId);
|
|
2183
|
+
await command.editReply(`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`);
|
|
2184
|
+
const starterMessage = await textChannel.send({
|
|
2185
|
+
content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
|
|
2186
|
+
});
|
|
2187
|
+
const thread = await starterMessage.startThread({
|
|
2188
|
+
name: `Init: ${sanitizedName}`,
|
|
2189
|
+
autoArchiveDuration: 1440,
|
|
2190
|
+
reason: 'New project session',
|
|
2191
|
+
});
|
|
2192
|
+
await handleOpencodeSession({
|
|
2193
|
+
prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
|
|
2194
|
+
thread,
|
|
2195
|
+
projectDirectory,
|
|
2196
|
+
});
|
|
2197
|
+
discordLogger.log(`Created new project ${channelName} at ${projectDirectory}`);
|
|
2198
|
+
}
|
|
2199
|
+
catch (error) {
|
|
2200
|
+
voiceLogger.error('[ADD-NEW-PROJECT] Error:', error);
|
|
2201
|
+
await command.editReply(`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2096
2204
|
else if (command.commandName === 'accept' ||
|
|
2097
2205
|
command.commandName === 'accept-always') {
|
|
2098
2206
|
const scope = command.commandName === 'accept-always' ? 'always' : 'once';
|
|
@@ -2264,6 +2372,70 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2264
2372
|
});
|
|
2265
2373
|
}
|
|
2266
2374
|
}
|
|
2375
|
+
else if (command.commandName === 'share') {
|
|
2376
|
+
const channel = command.channel;
|
|
2377
|
+
if (!channel) {
|
|
2378
|
+
await command.reply({
|
|
2379
|
+
content: 'This command can only be used in a channel',
|
|
2380
|
+
ephemeral: true,
|
|
2381
|
+
});
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
const isThread = [
|
|
2385
|
+
ChannelType.PublicThread,
|
|
2386
|
+
ChannelType.PrivateThread,
|
|
2387
|
+
ChannelType.AnnouncementThread,
|
|
2388
|
+
].includes(channel.type);
|
|
2389
|
+
if (!isThread) {
|
|
2390
|
+
await command.reply({
|
|
2391
|
+
content: 'This command can only be used in a thread with an active session',
|
|
2392
|
+
ephemeral: true,
|
|
2393
|
+
});
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
const textChannel = resolveTextChannel(channel);
|
|
2397
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
2398
|
+
if (!directory) {
|
|
2399
|
+
await command.reply({
|
|
2400
|
+
content: 'Could not determine project directory for this channel',
|
|
2401
|
+
ephemeral: true,
|
|
2402
|
+
});
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
const row = getDatabase()
|
|
2406
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
2407
|
+
.get(channel.id);
|
|
2408
|
+
if (!row?.session_id) {
|
|
2409
|
+
await command.reply({
|
|
2410
|
+
content: 'No active session in this thread',
|
|
2411
|
+
ephemeral: true,
|
|
2412
|
+
});
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
const sessionId = row.session_id;
|
|
2416
|
+
try {
|
|
2417
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
2418
|
+
const response = await getClient().session.share({
|
|
2419
|
+
path: { id: sessionId },
|
|
2420
|
+
});
|
|
2421
|
+
if (!response.data?.share?.url) {
|
|
2422
|
+
await command.reply({
|
|
2423
|
+
content: 'Failed to generate share URL',
|
|
2424
|
+
ephemeral: true,
|
|
2425
|
+
});
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
await command.reply(`🔗 **Session shared:** ${response.data.share.url}`);
|
|
2429
|
+
sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`);
|
|
2430
|
+
}
|
|
2431
|
+
catch (error) {
|
|
2432
|
+
voiceLogger.error('[SHARE] Error:', error);
|
|
2433
|
+
await command.reply({
|
|
2434
|
+
content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2435
|
+
ephemeral: true,
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2267
2439
|
}
|
|
2268
2440
|
}
|
|
2269
2441
|
catch (error) {
|
|
@@ -2479,7 +2651,7 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2479
2651
|
}
|
|
2480
2652
|
});
|
|
2481
2653
|
await discordClient.login(token);
|
|
2482
|
-
const handleShutdown = async (signal) => {
|
|
2654
|
+
const handleShutdown = async (signal, { skipExit = false } = {}) => {
|
|
2483
2655
|
discordLogger.log(`Received ${signal}, cleaning up...`);
|
|
2484
2656
|
// Prevent multiple shutdown calls
|
|
2485
2657
|
if (global.shuttingDown) {
|
|
@@ -2516,12 +2688,16 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2516
2688
|
}
|
|
2517
2689
|
discordLogger.log('Destroying Discord client...');
|
|
2518
2690
|
discordClient.destroy();
|
|
2519
|
-
discordLogger.log('Cleanup complete
|
|
2520
|
-
|
|
2691
|
+
discordLogger.log('Cleanup complete.');
|
|
2692
|
+
if (!skipExit) {
|
|
2693
|
+
process.exit(0);
|
|
2694
|
+
}
|
|
2521
2695
|
}
|
|
2522
2696
|
catch (error) {
|
|
2523
2697
|
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error);
|
|
2524
|
-
|
|
2698
|
+
if (!skipExit) {
|
|
2699
|
+
process.exit(1);
|
|
2700
|
+
}
|
|
2525
2701
|
}
|
|
2526
2702
|
};
|
|
2527
2703
|
// Override default signal handlers to prevent immediate exit
|
|
@@ -2543,6 +2719,23 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
2543
2719
|
process.exit(1);
|
|
2544
2720
|
}
|
|
2545
2721
|
});
|
|
2722
|
+
process.on('SIGUSR2', async () => {
|
|
2723
|
+
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|
|
2724
|
+
try {
|
|
2725
|
+
await handleShutdown('SIGUSR2', { skipExit: true });
|
|
2726
|
+
}
|
|
2727
|
+
catch (error) {
|
|
2728
|
+
voiceLogger.error('[SIGUSR2] Error during shutdown:', error);
|
|
2729
|
+
}
|
|
2730
|
+
const { spawn } = await import('node:child_process');
|
|
2731
|
+
spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
|
|
2732
|
+
stdio: 'inherit',
|
|
2733
|
+
detached: true,
|
|
2734
|
+
cwd: process.cwd(),
|
|
2735
|
+
env: process.env,
|
|
2736
|
+
}).unref();
|
|
2737
|
+
process.exit(0);
|
|
2738
|
+
});
|
|
2546
2739
|
// Prevent unhandled promise rejections from crashing the process during shutdown
|
|
2547
2740
|
process.on('unhandledRejection', (reason, promise) => {
|
|
2548
2741
|
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: {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { createLogger } from './logger.js';
|
|
3
|
+
const xaiLogger = createLogger('XAI');
|
|
4
|
+
export class XaiRealtimeClient {
|
|
5
|
+
ws = null;
|
|
6
|
+
apiKey;
|
|
7
|
+
baseUrl = 'wss://api.x.ai/v1/realtime';
|
|
8
|
+
callbacks;
|
|
9
|
+
isConnected = false;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.apiKey = options.apiKey;
|
|
12
|
+
this.callbacks = options.callbacks || {};
|
|
13
|
+
}
|
|
14
|
+
async connect() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
this.ws = new WebSocket(this.baseUrl, {
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
this.ws.on('open', () => {
|
|
22
|
+
xaiLogger.log('WebSocket connected');
|
|
23
|
+
this.isConnected = true;
|
|
24
|
+
this.callbacks.onOpen?.();
|
|
25
|
+
resolve();
|
|
26
|
+
});
|
|
27
|
+
this.ws.on('message', (data) => {
|
|
28
|
+
try {
|
|
29
|
+
const event = JSON.parse(data.toString());
|
|
30
|
+
this.callbacks.onMessage?.(event);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
xaiLogger.error('Failed to parse message:', error);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
this.ws.on('error', (error) => {
|
|
37
|
+
xaiLogger.error('WebSocket error:', error);
|
|
38
|
+
this.callbacks.onError?.(error);
|
|
39
|
+
if (!this.isConnected) {
|
|
40
|
+
reject(error);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
this.ws.on('close', (code, reason) => {
|
|
44
|
+
xaiLogger.log('WebSocket closed:', code, reason.toString());
|
|
45
|
+
this.isConnected = false;
|
|
46
|
+
this.callbacks.onClose?.({ code, reason: reason.toString() });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
send(event) {
|
|
51
|
+
if (!this.ws || !this.isConnected) {
|
|
52
|
+
throw new Error('WebSocket is not connected');
|
|
53
|
+
}
|
|
54
|
+
this.ws.send(JSON.stringify(event));
|
|
55
|
+
}
|
|
56
|
+
updateSession(session) {
|
|
57
|
+
this.send({ type: 'session.update', session });
|
|
58
|
+
}
|
|
59
|
+
appendInputAudio(base64Audio) {
|
|
60
|
+
this.send({ type: 'input_audio_buffer.append', audio: base64Audio });
|
|
61
|
+
}
|
|
62
|
+
commitInputAudio() {
|
|
63
|
+
this.send({ type: 'input_audio_buffer.commit' });
|
|
64
|
+
}
|
|
65
|
+
clearInputAudio() {
|
|
66
|
+
this.send({ type: 'input_audio_buffer.clear' });
|
|
67
|
+
}
|
|
68
|
+
createConversationItem(item, previousItemId) {
|
|
69
|
+
this.send({
|
|
70
|
+
type: 'conversation.item.create',
|
|
71
|
+
previous_item_id: previousItemId,
|
|
72
|
+
item,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
sendFunctionCallOutput(callId, output) {
|
|
76
|
+
this.createConversationItem({
|
|
77
|
+
type: 'function_call_output',
|
|
78
|
+
call_id: callId,
|
|
79
|
+
output,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
createResponse(modalities) {
|
|
83
|
+
this.send({ type: 'response.create', response: modalities ? { modalities } : undefined });
|
|
84
|
+
}
|
|
85
|
+
close() {
|
|
86
|
+
if (this.ws) {
|
|
87
|
+
this.ws.close();
|
|
88
|
+
this.ws = null;
|
|
89
|
+
this.isConnected = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
get connected() {
|
|
93
|
+
return this.isConnected;
|
|
94
|
+
}
|
|
95
|
+
}
|