kimaki 0.4.2 ā 0.4.6
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 +34 -38
- package/dist/discordBot.js +345 -145
- package/dist/escape-backticks.test.js +125 -0
- package/dist/tools.js +2 -1
- package/package.json +12 -14
- package/src/cli.ts +37 -50
- package/src/discordBot.ts +468 -159
- package/src/escape-backticks.test.ts +146 -0
- package/src/tools.ts +2 -1
package/dist/discordBot.js
CHANGED
|
@@ -327,7 +327,6 @@ function frameMono16khz() {
|
|
|
327
327
|
}
|
|
328
328
|
export function getDatabase() {
|
|
329
329
|
if (!db) {
|
|
330
|
-
// Create ~/.kimaki directory if it doesn't exist
|
|
331
330
|
const kimakiDir = path.join(os.homedir(), '.kimaki');
|
|
332
331
|
try {
|
|
333
332
|
fs.mkdirSync(kimakiDir, { recursive: true });
|
|
@@ -338,7 +337,6 @@ export function getDatabase() {
|
|
|
338
337
|
const dbPath = path.join(kimakiDir, 'discord-sessions.db');
|
|
339
338
|
dbLogger.log(`Opening database at: ${dbPath}`);
|
|
340
339
|
db = new Database(dbPath);
|
|
341
|
-
// Initialize tables
|
|
342
340
|
db.exec(`
|
|
343
341
|
CREATE TABLE IF NOT EXISTS thread_sessions (
|
|
344
342
|
thread_id TEXT PRIMARY KEY,
|
|
@@ -379,6 +377,51 @@ export function getDatabase() {
|
|
|
379
377
|
}
|
|
380
378
|
return db;
|
|
381
379
|
}
|
|
380
|
+
export async function ensureKimakiCategory(guild) {
|
|
381
|
+
const existingCategory = guild.channels.cache.find((channel) => {
|
|
382
|
+
if (channel.type !== ChannelType.GuildCategory) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
return channel.name.toLowerCase() === 'kimaki';
|
|
386
|
+
});
|
|
387
|
+
if (existingCategory) {
|
|
388
|
+
return existingCategory;
|
|
389
|
+
}
|
|
390
|
+
return guild.channels.create({
|
|
391
|
+
name: 'Kimaki',
|
|
392
|
+
type: ChannelType.GuildCategory,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
export async function createProjectChannels({ guild, projectDirectory, appId, }) {
|
|
396
|
+
const baseName = path.basename(projectDirectory);
|
|
397
|
+
const channelName = `${baseName}`
|
|
398
|
+
.toLowerCase()
|
|
399
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
400
|
+
.slice(0, 100);
|
|
401
|
+
const kimakiCategory = await ensureKimakiCategory(guild);
|
|
402
|
+
const textChannel = await guild.channels.create({
|
|
403
|
+
name: channelName,
|
|
404
|
+
type: ChannelType.GuildText,
|
|
405
|
+
parent: kimakiCategory,
|
|
406
|
+
topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
|
|
407
|
+
});
|
|
408
|
+
const voiceChannel = await guild.channels.create({
|
|
409
|
+
name: channelName,
|
|
410
|
+
type: ChannelType.GuildVoice,
|
|
411
|
+
parent: kimakiCategory,
|
|
412
|
+
});
|
|
413
|
+
getDatabase()
|
|
414
|
+
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
|
|
415
|
+
.run(textChannel.id, projectDirectory, 'text');
|
|
416
|
+
getDatabase()
|
|
417
|
+
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
|
|
418
|
+
.run(voiceChannel.id, projectDirectory, 'voice');
|
|
419
|
+
return {
|
|
420
|
+
textChannelId: textChannel.id,
|
|
421
|
+
voiceChannelId: voiceChannel.id,
|
|
422
|
+
channelName,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
382
425
|
async function getOpenPort() {
|
|
383
426
|
return new Promise((resolve, reject) => {
|
|
384
427
|
const server = net.createServer();
|
|
@@ -405,6 +448,7 @@ async function getOpenPort() {
|
|
|
405
448
|
*/
|
|
406
449
|
async function sendThreadMessage(thread, content) {
|
|
407
450
|
const MAX_LENGTH = 2000;
|
|
451
|
+
content = escapeBackticksInCodeBlocks(content);
|
|
408
452
|
// Simple case: content fits in one message
|
|
409
453
|
if (content.length <= MAX_LENGTH) {
|
|
410
454
|
return await thread.send(content);
|
|
@@ -551,6 +595,30 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
|
|
|
551
595
|
await sendThreadMessage(thread, `š **Transcribed message:** ${escapeDiscordFormatting(transcription)}`);
|
|
552
596
|
return transcription;
|
|
553
597
|
}
|
|
598
|
+
function getImageAttachments(message) {
|
|
599
|
+
const imageAttachments = Array.from(message.attachments.values()).filter((attachment) => attachment.contentType?.startsWith('image/'));
|
|
600
|
+
return imageAttachments.map((attachment) => ({
|
|
601
|
+
type: 'file',
|
|
602
|
+
mime: attachment.contentType || 'image/png',
|
|
603
|
+
filename: attachment.name,
|
|
604
|
+
url: attachment.url,
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
export function escapeBackticksInCodeBlocks(markdown) {
|
|
608
|
+
const lexer = new Lexer();
|
|
609
|
+
const tokens = lexer.lex(markdown);
|
|
610
|
+
let result = '';
|
|
611
|
+
for (const token of tokens) {
|
|
612
|
+
if (token.type === 'code') {
|
|
613
|
+
const escapedCode = token.text.replace(/`/g, '\\`');
|
|
614
|
+
result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n';
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
result += token.raw;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
554
622
|
/**
|
|
555
623
|
* Escape Discord formatting characters to prevent breaking code blocks and inline code
|
|
556
624
|
*/
|
|
@@ -708,130 +776,123 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
708
776
|
return entry.client;
|
|
709
777
|
};
|
|
710
778
|
}
|
|
779
|
+
function getToolSummaryText(part) {
|
|
780
|
+
if (part.type !== 'tool')
|
|
781
|
+
return '';
|
|
782
|
+
if (part.state.status !== 'completed' && part.state.status !== 'error')
|
|
783
|
+
return '';
|
|
784
|
+
if (part.tool === 'bash') {
|
|
785
|
+
const output = part.state.status === 'completed' ? part.state.output : part.state.error;
|
|
786
|
+
const lines = (output || '').split('\n').filter((l) => l.trim());
|
|
787
|
+
return `(${lines.length} line${lines.length === 1 ? '' : 's'})`;
|
|
788
|
+
}
|
|
789
|
+
if (part.tool === 'edit') {
|
|
790
|
+
const newString = part.state.input?.newString || '';
|
|
791
|
+
const oldString = part.state.input?.oldString || '';
|
|
792
|
+
const added = newString.split('\n').length;
|
|
793
|
+
const removed = oldString.split('\n').length;
|
|
794
|
+
return `(+${added}-${removed})`;
|
|
795
|
+
}
|
|
796
|
+
if (part.tool === 'write') {
|
|
797
|
+
const content = part.state.input?.content || '';
|
|
798
|
+
const lines = content.split('\n').length;
|
|
799
|
+
return `(${lines} line${lines === 1 ? '' : 's'})`;
|
|
800
|
+
}
|
|
801
|
+
if (part.tool === 'webfetch') {
|
|
802
|
+
const url = part.state.input?.url || '';
|
|
803
|
+
const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
|
|
804
|
+
return urlWithoutProtocol ? `(${urlWithoutProtocol})` : '';
|
|
805
|
+
}
|
|
806
|
+
if (part.tool === 'read' ||
|
|
807
|
+
part.tool === 'list' ||
|
|
808
|
+
part.tool === 'glob' ||
|
|
809
|
+
part.tool === 'grep' ||
|
|
810
|
+
part.tool === 'task' ||
|
|
811
|
+
part.tool === 'todoread' ||
|
|
812
|
+
part.tool === 'todowrite') {
|
|
813
|
+
return '';
|
|
814
|
+
}
|
|
815
|
+
if (!part.state.input)
|
|
816
|
+
return '';
|
|
817
|
+
const inputFields = Object.entries(part.state.input)
|
|
818
|
+
.map(([key, value]) => {
|
|
819
|
+
if (value === null || value === undefined)
|
|
820
|
+
return null;
|
|
821
|
+
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
822
|
+
const truncatedValue = stringValue.length > 100 ? stringValue.slice(0, 100) + 'ā¦' : stringValue;
|
|
823
|
+
return `${key}: ${truncatedValue}`;
|
|
824
|
+
})
|
|
825
|
+
.filter(Boolean);
|
|
826
|
+
if (inputFields.length === 0)
|
|
827
|
+
return '';
|
|
828
|
+
return `(${inputFields.join(', ')})`;
|
|
829
|
+
}
|
|
830
|
+
function getToolOutputToDisplay(part) {
|
|
831
|
+
if (part.type !== 'tool')
|
|
832
|
+
return '';
|
|
833
|
+
if (part.state.status !== 'completed' && part.state.status !== 'error')
|
|
834
|
+
return '';
|
|
835
|
+
if (part.state.status === 'error') {
|
|
836
|
+
return part.state.error || 'Unknown error';
|
|
837
|
+
}
|
|
838
|
+
if (part.tool === 'todowrite') {
|
|
839
|
+
const todos = part.state.input?.todos || [];
|
|
840
|
+
return todos
|
|
841
|
+
.map((todo) => {
|
|
842
|
+
let statusIcon = 'ā¢';
|
|
843
|
+
if (todo.status === 'in_progress') {
|
|
844
|
+
statusIcon = 'ā';
|
|
845
|
+
}
|
|
846
|
+
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
847
|
+
statusIcon = 'ā ';
|
|
848
|
+
}
|
|
849
|
+
return `\`${statusIcon}\` ${todo.content}`;
|
|
850
|
+
})
|
|
851
|
+
.filter(Boolean)
|
|
852
|
+
.join('\n');
|
|
853
|
+
}
|
|
854
|
+
return '';
|
|
855
|
+
}
|
|
711
856
|
function formatPart(part) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
return '';
|
|
718
|
-
return `āŖļø thinking: ${escapeDiscordFormatting(part.text || '')}`;
|
|
719
|
-
case 'tool':
|
|
720
|
-
if (part.state.status === 'completed' || part.state.status === 'error') {
|
|
721
|
-
let outputToDisplay = '';
|
|
722
|
-
let summaryText = '';
|
|
723
|
-
if (part.tool === 'bash') {
|
|
724
|
-
const output = part.state.status === 'completed'
|
|
725
|
-
? part.state.output
|
|
726
|
-
: part.state.error;
|
|
727
|
-
const lines = (output || '').split('\n').filter((l) => l.trim());
|
|
728
|
-
summaryText = `(${lines.length} line${lines.length === 1 ? '' : 's'})`;
|
|
729
|
-
}
|
|
730
|
-
else if (part.tool === 'edit') {
|
|
731
|
-
const newString = part.state.input?.newString || '';
|
|
732
|
-
const oldString = part.state.input?.oldString || '';
|
|
733
|
-
const added = newString.split('\n').length;
|
|
734
|
-
const removed = oldString.split('\n').length;
|
|
735
|
-
summaryText = `(+${added}-${removed})`;
|
|
736
|
-
}
|
|
737
|
-
else if (part.tool === 'write') {
|
|
738
|
-
const content = part.state.input?.content || '';
|
|
739
|
-
const lines = content.split('\n').length;
|
|
740
|
-
summaryText = `(${lines} line${lines === 1 ? '' : 's'})`;
|
|
741
|
-
}
|
|
742
|
-
else if (part.tool === 'read') {
|
|
743
|
-
}
|
|
744
|
-
else if (part.tool === 'write') {
|
|
745
|
-
}
|
|
746
|
-
else if (part.tool === 'edit') {
|
|
747
|
-
}
|
|
748
|
-
else if (part.tool === 'list') {
|
|
749
|
-
}
|
|
750
|
-
else if (part.tool === 'glob') {
|
|
751
|
-
}
|
|
752
|
-
else if (part.tool === 'grep') {
|
|
753
|
-
}
|
|
754
|
-
else if (part.tool === 'task') {
|
|
755
|
-
}
|
|
756
|
-
else if (part.tool === 'todoread') {
|
|
757
|
-
// Special handling for read - don't show arguments
|
|
758
|
-
}
|
|
759
|
-
else if (part.tool === 'todowrite') {
|
|
760
|
-
const todos = part.state.input?.todos || [];
|
|
761
|
-
outputToDisplay = todos
|
|
762
|
-
.map((todo) => {
|
|
763
|
-
let statusIcon = 'ā¢';
|
|
764
|
-
switch (todo.status) {
|
|
765
|
-
case 'pending':
|
|
766
|
-
statusIcon = 'ā¢';
|
|
767
|
-
break;
|
|
768
|
-
case 'in_progress':
|
|
769
|
-
statusIcon = 'ā';
|
|
770
|
-
break;
|
|
771
|
-
case 'completed':
|
|
772
|
-
statusIcon = 'ā ';
|
|
773
|
-
break;
|
|
774
|
-
case 'cancelled':
|
|
775
|
-
statusIcon = 'ā ';
|
|
776
|
-
break;
|
|
777
|
-
}
|
|
778
|
-
return `\`${statusIcon}\` ${todo.content}`;
|
|
779
|
-
})
|
|
780
|
-
.filter(Boolean)
|
|
781
|
-
.join('\n');
|
|
782
|
-
}
|
|
783
|
-
else if (part.tool === 'webfetch') {
|
|
784
|
-
const url = part.state.input?.url || '';
|
|
785
|
-
const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
|
|
786
|
-
summaryText = urlWithoutProtocol ? `(${urlWithoutProtocol})` : '';
|
|
787
|
-
}
|
|
788
|
-
else if (part.state.input) {
|
|
789
|
-
const inputFields = Object.entries(part.state.input)
|
|
790
|
-
.map(([key, value]) => {
|
|
791
|
-
if (value === null || value === undefined)
|
|
792
|
-
return null;
|
|
793
|
-
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
794
|
-
const truncatedValue = stringValue.length > 100
|
|
795
|
-
? stringValue.slice(0, 100) + 'ā¦'
|
|
796
|
-
: stringValue;
|
|
797
|
-
return `${key}: ${truncatedValue}`;
|
|
798
|
-
})
|
|
799
|
-
.filter(Boolean);
|
|
800
|
-
if (inputFields.length > 0) {
|
|
801
|
-
outputToDisplay = inputFields.join(', ');
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error';
|
|
805
|
-
if (toolTitle) {
|
|
806
|
-
toolTitle = `\`${escapeInlineCode(toolTitle)}\``;
|
|
807
|
-
}
|
|
808
|
-
const icon = part.state.status === 'completed'
|
|
809
|
-
? 'ā¼ļø'
|
|
810
|
-
: part.state.status === 'error'
|
|
811
|
-
? '⨯'
|
|
812
|
-
: '';
|
|
813
|
-
const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
814
|
-
let text = title;
|
|
815
|
-
if (outputToDisplay) {
|
|
816
|
-
text += '\n\n' + outputToDisplay;
|
|
817
|
-
}
|
|
818
|
-
return text;
|
|
819
|
-
}
|
|
820
|
-
return '';
|
|
821
|
-
case 'file':
|
|
822
|
-
return `š ${part.filename || 'File'}`;
|
|
823
|
-
case 'step-start':
|
|
824
|
-
case 'step-finish':
|
|
825
|
-
case 'patch':
|
|
857
|
+
if (part.type === 'text') {
|
|
858
|
+
return part.text || '';
|
|
859
|
+
}
|
|
860
|
+
if (part.type === 'reasoning') {
|
|
861
|
+
if (!part.text?.trim())
|
|
826
862
|
return '';
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
863
|
+
return `ā¼ļø thinking`;
|
|
864
|
+
}
|
|
865
|
+
if (part.type === 'file') {
|
|
866
|
+
return `š ${part.filename || 'File'}`;
|
|
867
|
+
}
|
|
868
|
+
if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
|
|
869
|
+
return '';
|
|
870
|
+
}
|
|
871
|
+
if (part.type === 'agent') {
|
|
872
|
+
return `ā¼ļø agent ${part.id}`;
|
|
873
|
+
}
|
|
874
|
+
if (part.type === 'snapshot') {
|
|
875
|
+
return `ā¼ļø snapshot ${part.snapshot}`;
|
|
876
|
+
}
|
|
877
|
+
if (part.type === 'tool') {
|
|
878
|
+
if (part.state.status !== 'completed' && part.state.status !== 'error') {
|
|
833
879
|
return '';
|
|
880
|
+
}
|
|
881
|
+
const summaryText = getToolSummaryText(part);
|
|
882
|
+
const outputToDisplay = getToolOutputToDisplay(part);
|
|
883
|
+
let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error';
|
|
884
|
+
if (toolTitle) {
|
|
885
|
+
toolTitle = `*${toolTitle}*`;
|
|
886
|
+
}
|
|
887
|
+
const icon = part.state.status === 'completed' ? 'ā¼ļø' : part.state.status === 'error' ? '⨯' : '';
|
|
888
|
+
const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
889
|
+
if (outputToDisplay) {
|
|
890
|
+
return title + '\n\n' + outputToDisplay;
|
|
891
|
+
}
|
|
892
|
+
return title;
|
|
834
893
|
}
|
|
894
|
+
discordLogger.warn('Unknown part type:', part);
|
|
895
|
+
return '';
|
|
835
896
|
}
|
|
836
897
|
export async function createDiscordClient() {
|
|
837
898
|
return new Client({
|
|
@@ -849,7 +910,7 @@ export async function createDiscordClient() {
|
|
|
849
910
|
],
|
|
850
911
|
});
|
|
851
912
|
}
|
|
852
|
-
async function handleOpencodeSession(prompt, thread, projectDirectory, originalMessage) {
|
|
913
|
+
async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], }) {
|
|
853
914
|
voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
|
|
854
915
|
// Track session start time
|
|
855
916
|
const sessionStartTime = Date.now();
|
|
@@ -868,6 +929,9 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
868
929
|
sessionLogger.log(`Using directory: ${directory}`);
|
|
869
930
|
// Note: We'll cancel the existing request after we have the session ID
|
|
870
931
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
932
|
+
// Get the port for this directory
|
|
933
|
+
const serverEntry = opencodeServers.get(directory);
|
|
934
|
+
const port = serverEntry?.port;
|
|
871
935
|
// Get session ID from database
|
|
872
936
|
const row = getDatabase()
|
|
873
937
|
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
@@ -939,6 +1003,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
939
1003
|
}
|
|
940
1004
|
let currentParts = [];
|
|
941
1005
|
let stopTyping = null;
|
|
1006
|
+
let usedModel;
|
|
942
1007
|
const sendPartMessage = async (part) => {
|
|
943
1008
|
const content = formatPart(part) + '\n\n';
|
|
944
1009
|
if (!content.trim() || content.length === 0) {
|
|
@@ -1023,6 +1088,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1023
1088
|
// Track assistant message ID
|
|
1024
1089
|
if (msg.role === 'assistant') {
|
|
1025
1090
|
assistantMessageId = msg.id;
|
|
1091
|
+
usedModel = msg.modelID;
|
|
1026
1092
|
voiceLogger.log(`[EVENT] Tracking assistant message ${assistantMessageId}`);
|
|
1027
1093
|
}
|
|
1028
1094
|
else {
|
|
@@ -1142,8 +1208,10 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1142
1208
|
if (!abortController.signal.aborted ||
|
|
1143
1209
|
abortController.signal.reason === 'finished') {
|
|
1144
1210
|
const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
|
|
1145
|
-
|
|
1146
|
-
|
|
1211
|
+
const attachCommand = port ? ` ā
${session.id}` : '';
|
|
1212
|
+
const modelInfo = usedModel ? ` ā
${usedModel}` : '';
|
|
1213
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}_${attachCommand}${modelInfo}`);
|
|
1214
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}`);
|
|
1147
1215
|
}
|
|
1148
1216
|
else {
|
|
1149
1217
|
sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
|
|
@@ -1152,18 +1220,22 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1152
1220
|
};
|
|
1153
1221
|
try {
|
|
1154
1222
|
voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
|
|
1223
|
+
if (images.length > 0) {
|
|
1224
|
+
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
|
|
1225
|
+
}
|
|
1155
1226
|
// Start the event handler
|
|
1156
1227
|
const eventHandlerPromise = eventHandler();
|
|
1228
|
+
const parts = [{ type: 'text', text: prompt }, ...images];
|
|
1229
|
+
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
|
|
1157
1230
|
const response = await getClient().session.prompt({
|
|
1158
1231
|
path: { id: session.id },
|
|
1159
1232
|
body: {
|
|
1160
|
-
parts
|
|
1233
|
+
parts,
|
|
1161
1234
|
},
|
|
1162
1235
|
signal: abortController.signal,
|
|
1163
1236
|
});
|
|
1164
|
-
abortController.abort(
|
|
1237
|
+
abortController.abort('finished');
|
|
1165
1238
|
sessionLogger.log(`Successfully sent prompt, got response`);
|
|
1166
|
-
abortControllers.delete(session.id);
|
|
1167
1239
|
// Update reaction to success
|
|
1168
1240
|
if (originalMessage) {
|
|
1169
1241
|
try {
|
|
@@ -1175,12 +1247,12 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1175
1247
|
discordLogger.log(`Could not update reaction:`, e);
|
|
1176
1248
|
}
|
|
1177
1249
|
}
|
|
1178
|
-
return { sessionID: session.id, result: response.data };
|
|
1250
|
+
return { sessionID: session.id, result: response.data, port };
|
|
1179
1251
|
}
|
|
1180
1252
|
catch (error) {
|
|
1181
1253
|
sessionLogger.error(`ERROR: Failed to send prompt:`, error);
|
|
1182
1254
|
if (!isAbortError(error, abortController.signal)) {
|
|
1183
|
-
abortController.abort(
|
|
1255
|
+
abortController.abort('error');
|
|
1184
1256
|
if (originalMessage) {
|
|
1185
1257
|
try {
|
|
1186
1258
|
await originalMessage.reactions.removeAll();
|
|
@@ -1191,7 +1263,6 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1191
1263
|
discordLogger.log(`Could not update reaction:`, e);
|
|
1192
1264
|
}
|
|
1193
1265
|
}
|
|
1194
|
-
// Always log the error's constructor name (if any) and make error reporting more readable
|
|
1195
1266
|
const errorName = error &&
|
|
1196
1267
|
typeof error === 'object' &&
|
|
1197
1268
|
'constructor' in error &&
|
|
@@ -1350,7 +1421,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1350
1421
|
if (transcription) {
|
|
1351
1422
|
messageContent = transcription;
|
|
1352
1423
|
}
|
|
1353
|
-
|
|
1424
|
+
const images = getImageAttachments(message);
|
|
1425
|
+
await handleOpencodeSession({
|
|
1426
|
+
prompt: messageContent,
|
|
1427
|
+
thread,
|
|
1428
|
+
projectDirectory,
|
|
1429
|
+
originalMessage: message,
|
|
1430
|
+
images,
|
|
1431
|
+
});
|
|
1354
1432
|
return;
|
|
1355
1433
|
}
|
|
1356
1434
|
// For text channels, start new sessions with kimaki.directory tag
|
|
@@ -1409,7 +1487,14 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1409
1487
|
if (transcription) {
|
|
1410
1488
|
messageContent = transcription;
|
|
1411
1489
|
}
|
|
1412
|
-
|
|
1490
|
+
const images = getImageAttachments(message);
|
|
1491
|
+
await handleOpencodeSession({
|
|
1492
|
+
prompt: messageContent,
|
|
1493
|
+
thread,
|
|
1494
|
+
projectDirectory,
|
|
1495
|
+
originalMessage: message,
|
|
1496
|
+
images,
|
|
1497
|
+
});
|
|
1413
1498
|
}
|
|
1414
1499
|
else {
|
|
1415
1500
|
discordLogger.log(`Channel type ${channel.type} is not supported`);
|
|
@@ -1466,10 +1551,20 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1466
1551
|
.toLowerCase()
|
|
1467
1552
|
.includes(focusedValue.toLowerCase()))
|
|
1468
1553
|
.slice(0, 25) // Discord limit
|
|
1469
|
-
.map((session) =>
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1554
|
+
.map((session) => {
|
|
1555
|
+
const dateStr = new Date(session.time.updated).toLocaleString();
|
|
1556
|
+
const suffix = ` (${dateStr})`;
|
|
1557
|
+
// Discord limit is 100 chars. Reserve space for suffix.
|
|
1558
|
+
const maxTitleLength = 100 - suffix.length;
|
|
1559
|
+
let title = session.title;
|
|
1560
|
+
if (title.length > maxTitleLength) {
|
|
1561
|
+
title = title.slice(0, Math.max(0, maxTitleLength - 1)) + 'ā¦';
|
|
1562
|
+
}
|
|
1563
|
+
return {
|
|
1564
|
+
name: `${title}${suffix}`,
|
|
1565
|
+
value: session.id,
|
|
1566
|
+
};
|
|
1567
|
+
});
|
|
1473
1568
|
await interaction.respond(sessions);
|
|
1474
1569
|
}
|
|
1475
1570
|
catch (error) {
|
|
@@ -1523,7 +1618,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1523
1618
|
: '';
|
|
1524
1619
|
// Map to Discord autocomplete format
|
|
1525
1620
|
const choices = files
|
|
1526
|
-
.slice(0, 25) // Discord limit
|
|
1527
1621
|
.map((file) => {
|
|
1528
1622
|
const fullValue = prefix + file;
|
|
1529
1623
|
// Get all basenames for display
|
|
@@ -1538,7 +1632,10 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1538
1632
|
name: displayName,
|
|
1539
1633
|
value: fullValue,
|
|
1540
1634
|
};
|
|
1541
|
-
})
|
|
1635
|
+
})
|
|
1636
|
+
// Discord API limits choice value to 100 characters
|
|
1637
|
+
.filter((choice) => choice.value.length <= 100)
|
|
1638
|
+
.slice(0, 25); // Discord limit
|
|
1542
1639
|
await interaction.respond(choices);
|
|
1543
1640
|
}
|
|
1544
1641
|
catch (error) {
|
|
@@ -1547,6 +1644,48 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1547
1644
|
}
|
|
1548
1645
|
}
|
|
1549
1646
|
}
|
|
1647
|
+
else if (interaction.commandName === 'add-project') {
|
|
1648
|
+
const focusedValue = interaction.options.getFocused();
|
|
1649
|
+
try {
|
|
1650
|
+
const currentDir = process.cwd();
|
|
1651
|
+
const getClient = await initializeOpencodeForDirectory(currentDir);
|
|
1652
|
+
const projectsResponse = await getClient().project.list({});
|
|
1653
|
+
if (!projectsResponse.data) {
|
|
1654
|
+
await interaction.respond([]);
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const db = getDatabase();
|
|
1658
|
+
const existingDirs = db
|
|
1659
|
+
.prepare('SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?')
|
|
1660
|
+
.all('text');
|
|
1661
|
+
const existingDirSet = new Set(existingDirs.map((row) => row.directory));
|
|
1662
|
+
const availableProjects = projectsResponse.data.filter((project) => !existingDirSet.has(project.worktree));
|
|
1663
|
+
const projects = availableProjects
|
|
1664
|
+
.filter((project) => {
|
|
1665
|
+
const baseName = path.basename(project.worktree);
|
|
1666
|
+
const searchText = `${baseName} ${project.worktree}`.toLowerCase();
|
|
1667
|
+
return searchText.includes(focusedValue.toLowerCase());
|
|
1668
|
+
})
|
|
1669
|
+
.sort((a, b) => {
|
|
1670
|
+
const aTime = a.time.initialized || a.time.created;
|
|
1671
|
+
const bTime = b.time.initialized || b.time.created;
|
|
1672
|
+
return bTime - aTime;
|
|
1673
|
+
})
|
|
1674
|
+
.slice(0, 25)
|
|
1675
|
+
.map((project) => {
|
|
1676
|
+
const name = `${path.basename(project.worktree)} (${project.worktree})`;
|
|
1677
|
+
return {
|
|
1678
|
+
name: name.length > 100 ? name.slice(0, 99) + 'ā¦' : name,
|
|
1679
|
+
value: project.id,
|
|
1680
|
+
};
|
|
1681
|
+
});
|
|
1682
|
+
await interaction.respond(projects);
|
|
1683
|
+
}
|
|
1684
|
+
catch (error) {
|
|
1685
|
+
voiceLogger.error('[AUTOCOMPLETE] Error fetching projects:', error);
|
|
1686
|
+
await interaction.respond([]);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1550
1689
|
}
|
|
1551
1690
|
// Handle slash commands
|
|
1552
1691
|
if (interaction.isChatInputCommand()) {
|
|
@@ -1610,7 +1749,11 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1610
1749
|
});
|
|
1611
1750
|
await command.editReply(`Created new session in ${thread.toString()}`);
|
|
1612
1751
|
// Start the OpenCode session
|
|
1613
|
-
await handleOpencodeSession(
|
|
1752
|
+
await handleOpencodeSession({
|
|
1753
|
+
prompt: fullPrompt,
|
|
1754
|
+
thread,
|
|
1755
|
+
projectDirectory,
|
|
1756
|
+
});
|
|
1614
1757
|
}
|
|
1615
1758
|
catch (error) {
|
|
1616
1759
|
voiceLogger.error('[SESSION] Error:', error);
|
|
@@ -1707,16 +1850,26 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1707
1850
|
}
|
|
1708
1851
|
else if (message.info.role === 'assistant') {
|
|
1709
1852
|
// Render assistant parts
|
|
1853
|
+
const partsToRender = [];
|
|
1710
1854
|
for (const part of message.parts) {
|
|
1711
1855
|
const content = formatPart(part);
|
|
1712
1856
|
if (content.trim()) {
|
|
1713
|
-
|
|
1714
|
-
// Store part-message mapping in database
|
|
1715
|
-
getDatabase()
|
|
1716
|
-
.prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
|
|
1717
|
-
.run(part.id, discordMessage.id, thread.id);
|
|
1857
|
+
partsToRender.push({ id: part.id, content });
|
|
1718
1858
|
}
|
|
1719
1859
|
}
|
|
1860
|
+
if (partsToRender.length > 0) {
|
|
1861
|
+
const combinedContent = partsToRender
|
|
1862
|
+
.map((p) => p.content)
|
|
1863
|
+
.join('\n\n');
|
|
1864
|
+
const discordMessage = await sendThreadMessage(thread, combinedContent);
|
|
1865
|
+
const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
|
|
1866
|
+
const transaction = getDatabase().transaction((parts) => {
|
|
1867
|
+
for (const part of parts) {
|
|
1868
|
+
stmt.run(part.id, discordMessage.id, thread.id);
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
transaction(partsToRender);
|
|
1872
|
+
}
|
|
1720
1873
|
}
|
|
1721
1874
|
messageCount++;
|
|
1722
1875
|
}
|
|
@@ -1727,6 +1880,53 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1727
1880
|
await command.editReply(`Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
1728
1881
|
}
|
|
1729
1882
|
}
|
|
1883
|
+
else if (command.commandName === 'add-project') {
|
|
1884
|
+
await command.deferReply({ ephemeral: false });
|
|
1885
|
+
const projectId = command.options.getString('project', true);
|
|
1886
|
+
const guild = command.guild;
|
|
1887
|
+
if (!guild) {
|
|
1888
|
+
await command.editReply('This command can only be used in a guild');
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
try {
|
|
1892
|
+
const currentDir = process.cwd();
|
|
1893
|
+
const getClient = await initializeOpencodeForDirectory(currentDir);
|
|
1894
|
+
const projectsResponse = await getClient().project.list({});
|
|
1895
|
+
if (!projectsResponse.data) {
|
|
1896
|
+
await command.editReply('Failed to fetch projects');
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
const project = projectsResponse.data.find((p) => p.id === projectId);
|
|
1900
|
+
if (!project) {
|
|
1901
|
+
await command.editReply('Project not found');
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
const directory = project.worktree;
|
|
1905
|
+
if (!fs.existsSync(directory)) {
|
|
1906
|
+
await command.editReply(`Directory does not exist: ${directory}`);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
const db = getDatabase();
|
|
1910
|
+
const existingChannel = db
|
|
1911
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?')
|
|
1912
|
+
.get(directory, 'text');
|
|
1913
|
+
if (existingChannel) {
|
|
1914
|
+
await command.editReply(`A channel already exists for this directory: <#${existingChannel.channel_id}>`);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
1918
|
+
guild,
|
|
1919
|
+
projectDirectory: directory,
|
|
1920
|
+
appId: currentAppId,
|
|
1921
|
+
});
|
|
1922
|
+
await command.editReply(`ā
Created channels for project:\nš Text: <#${textChannelId}>\nš Voice: <#${voiceChannelId}>\nš Directory: \`${directory}\``);
|
|
1923
|
+
discordLogger.log(`Created channels for project ${channelName} at ${directory}`);
|
|
1924
|
+
}
|
|
1925
|
+
catch (error) {
|
|
1926
|
+
voiceLogger.error('[ADD-PROJECT] Error:', error);
|
|
1927
|
+
await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1730
1930
|
}
|
|
1731
1931
|
}
|
|
1732
1932
|
catch (error) {
|