kimaki 0.4.2 ā 0.4.4
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 +20 -37
- package/dist/discordBot.js +293 -135
- package/dist/escape-backticks.test.js +125 -0
- package/package.json +11 -13
- package/src/cli.ts +22 -49
- package/src/discordBot.ts +390 -133
- package/src/escape-backticks.test.ts +146 -0
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { cac } from 'cac';
|
|
3
3
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
|
|
4
4
|
import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
|
|
5
|
-
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, } from './discordBot.js';
|
|
5
|
+
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discordBot.js';
|
|
6
6
|
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord.js';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import fs from 'node:fs';
|
|
@@ -45,6 +45,18 @@ async function registerCommands(token, appId) {
|
|
|
45
45
|
return option;
|
|
46
46
|
})
|
|
47
47
|
.toJSON(),
|
|
48
|
+
new SlashCommandBuilder()
|
|
49
|
+
.setName('add-project')
|
|
50
|
+
.setDescription('Create Discord channels for a new OpenCode project')
|
|
51
|
+
.addStringOption((option) => {
|
|
52
|
+
option
|
|
53
|
+
.setName('project')
|
|
54
|
+
.setDescription('Select an OpenCode project')
|
|
55
|
+
.setRequired(true)
|
|
56
|
+
.setAutocomplete(true);
|
|
57
|
+
return option;
|
|
58
|
+
})
|
|
59
|
+
.toJSON(),
|
|
48
60
|
];
|
|
49
61
|
const rest = new REST().setToken(token);
|
|
50
62
|
try {
|
|
@@ -58,21 +70,6 @@ async function registerCommands(token, appId) {
|
|
|
58
70
|
throw error;
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
|
-
async function ensureKimakiCategory(guild) {
|
|
62
|
-
const existingCategory = guild.channels.cache.find((channel) => {
|
|
63
|
-
if (channel.type !== ChannelType.GuildCategory) {
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
return channel.name.toLowerCase() === 'kimaki';
|
|
67
|
-
});
|
|
68
|
-
if (existingCategory) {
|
|
69
|
-
return existingCategory;
|
|
70
|
-
}
|
|
71
|
-
return guild.channels.create({
|
|
72
|
-
name: 'Kimaki',
|
|
73
|
-
type: ChannelType.GuildCategory,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
73
|
async function run({ restart, addChannels }) {
|
|
77
74
|
const forceSetup = Boolean(restart);
|
|
78
75
|
intro('š¤ Discord Bot Setup');
|
|
@@ -344,34 +341,20 @@ async function run({ restart, addChannels }) {
|
|
|
344
341
|
const project = projects.find((p) => p.id === projectId);
|
|
345
342
|
if (!project)
|
|
346
343
|
continue;
|
|
347
|
-
const baseName = path.basename(project.worktree);
|
|
348
|
-
const channelName = `${baseName}`
|
|
349
|
-
.toLowerCase()
|
|
350
|
-
.replace(/[^a-z0-9-]/g, '-')
|
|
351
|
-
.slice(0, 100);
|
|
352
344
|
try {
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
parent: kimakiCategory,
|
|
358
|
-
topic: `<kimaki><directory>${project.worktree}</directory><app>${appId}</app></kimaki>`,
|
|
359
|
-
});
|
|
360
|
-
const voiceChannel = await targetGuild.channels.create({
|
|
361
|
-
name: channelName,
|
|
362
|
-
type: ChannelType.GuildVoice,
|
|
363
|
-
parent: kimakiCategory,
|
|
345
|
+
const { textChannelId, channelName } = await createProjectChannels({
|
|
346
|
+
guild: targetGuild,
|
|
347
|
+
projectDirectory: project.worktree,
|
|
348
|
+
appId,
|
|
364
349
|
});
|
|
365
|
-
db.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(textChannel.id, project.worktree, 'text');
|
|
366
|
-
db.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(voiceChannel.id, project.worktree, 'voice');
|
|
367
350
|
createdChannels.push({
|
|
368
|
-
name:
|
|
369
|
-
id:
|
|
351
|
+
name: channelName,
|
|
352
|
+
id: textChannelId,
|
|
370
353
|
guildId: targetGuild.id,
|
|
371
354
|
});
|
|
372
355
|
}
|
|
373
356
|
catch (error) {
|
|
374
|
-
cliLogger.error(`Failed to create channels for ${
|
|
357
|
+
cliLogger.error(`Failed to create channels for ${path.basename(project.worktree)}:`, error);
|
|
375
358
|
}
|
|
376
359
|
}
|
|
377
360
|
s.stop(`Created ${createdChannels.length} channel(s)`);
|
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,21 @@ async function processVoiceAttachment({ message, thread, projectDirectory, isNew
|
|
|
551
595
|
await sendThreadMessage(thread, `š **Transcribed message:** ${escapeDiscordFormatting(transcription)}`);
|
|
552
596
|
return transcription;
|
|
553
597
|
}
|
|
598
|
+
export function escapeBackticksInCodeBlocks(markdown) {
|
|
599
|
+
const lexer = new Lexer();
|
|
600
|
+
const tokens = lexer.lex(markdown);
|
|
601
|
+
let result = '';
|
|
602
|
+
for (const token of tokens) {
|
|
603
|
+
if (token.type === 'code') {
|
|
604
|
+
const escapedCode = token.text.replace(/`/g, '\\`');
|
|
605
|
+
result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n';
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
result += token.raw;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return result;
|
|
612
|
+
}
|
|
554
613
|
/**
|
|
555
614
|
* Escape Discord formatting characters to prevent breaking code blocks and inline code
|
|
556
615
|
*/
|
|
@@ -708,130 +767,123 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
708
767
|
return entry.client;
|
|
709
768
|
};
|
|
710
769
|
}
|
|
770
|
+
function getToolSummaryText(part) {
|
|
771
|
+
if (part.type !== 'tool')
|
|
772
|
+
return '';
|
|
773
|
+
if (part.state.status !== 'completed' && part.state.status !== 'error')
|
|
774
|
+
return '';
|
|
775
|
+
if (part.tool === 'bash') {
|
|
776
|
+
const output = part.state.status === 'completed' ? part.state.output : part.state.error;
|
|
777
|
+
const lines = (output || '').split('\n').filter((l) => l.trim());
|
|
778
|
+
return `(${lines.length} line${lines.length === 1 ? '' : 's'})`;
|
|
779
|
+
}
|
|
780
|
+
if (part.tool === 'edit') {
|
|
781
|
+
const newString = part.state.input?.newString || '';
|
|
782
|
+
const oldString = part.state.input?.oldString || '';
|
|
783
|
+
const added = newString.split('\n').length;
|
|
784
|
+
const removed = oldString.split('\n').length;
|
|
785
|
+
return `(+${added}-${removed})`;
|
|
786
|
+
}
|
|
787
|
+
if (part.tool === 'write') {
|
|
788
|
+
const content = part.state.input?.content || '';
|
|
789
|
+
const lines = content.split('\n').length;
|
|
790
|
+
return `(${lines} line${lines === 1 ? '' : 's'})`;
|
|
791
|
+
}
|
|
792
|
+
if (part.tool === 'webfetch') {
|
|
793
|
+
const url = part.state.input?.url || '';
|
|
794
|
+
const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
|
|
795
|
+
return urlWithoutProtocol ? `(${urlWithoutProtocol})` : '';
|
|
796
|
+
}
|
|
797
|
+
if (part.tool === 'read' ||
|
|
798
|
+
part.tool === 'list' ||
|
|
799
|
+
part.tool === 'glob' ||
|
|
800
|
+
part.tool === 'grep' ||
|
|
801
|
+
part.tool === 'task' ||
|
|
802
|
+
part.tool === 'todoread' ||
|
|
803
|
+
part.tool === 'todowrite') {
|
|
804
|
+
return '';
|
|
805
|
+
}
|
|
806
|
+
if (!part.state.input)
|
|
807
|
+
return '';
|
|
808
|
+
const inputFields = Object.entries(part.state.input)
|
|
809
|
+
.map(([key, value]) => {
|
|
810
|
+
if (value === null || value === undefined)
|
|
811
|
+
return null;
|
|
812
|
+
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
813
|
+
const truncatedValue = stringValue.length > 100 ? stringValue.slice(0, 100) + 'ā¦' : stringValue;
|
|
814
|
+
return `${key}: ${truncatedValue}`;
|
|
815
|
+
})
|
|
816
|
+
.filter(Boolean);
|
|
817
|
+
if (inputFields.length === 0)
|
|
818
|
+
return '';
|
|
819
|
+
return `(${inputFields.join(', ')})`;
|
|
820
|
+
}
|
|
821
|
+
function getToolOutputToDisplay(part) {
|
|
822
|
+
if (part.type !== 'tool')
|
|
823
|
+
return '';
|
|
824
|
+
if (part.state.status !== 'completed' && part.state.status !== 'error')
|
|
825
|
+
return '';
|
|
826
|
+
if (part.state.status === 'error') {
|
|
827
|
+
return part.state.error || 'Unknown error';
|
|
828
|
+
}
|
|
829
|
+
if (part.tool === 'todowrite') {
|
|
830
|
+
const todos = part.state.input?.todos || [];
|
|
831
|
+
return todos
|
|
832
|
+
.map((todo) => {
|
|
833
|
+
let statusIcon = 'ā¢';
|
|
834
|
+
if (todo.status === 'in_progress') {
|
|
835
|
+
statusIcon = 'ā';
|
|
836
|
+
}
|
|
837
|
+
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
838
|
+
statusIcon = 'ā ';
|
|
839
|
+
}
|
|
840
|
+
return `\`${statusIcon}\` ${todo.content}`;
|
|
841
|
+
})
|
|
842
|
+
.filter(Boolean)
|
|
843
|
+
.join('\n');
|
|
844
|
+
}
|
|
845
|
+
return '';
|
|
846
|
+
}
|
|
711
847
|
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':
|
|
848
|
+
if (part.type === 'text') {
|
|
849
|
+
return part.text || '';
|
|
850
|
+
}
|
|
851
|
+
if (part.type === 'reasoning') {
|
|
852
|
+
if (!part.text?.trim())
|
|
826
853
|
return '';
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
854
|
+
return `ā¼ļø thinking`;
|
|
855
|
+
}
|
|
856
|
+
if (part.type === 'file') {
|
|
857
|
+
return `š ${part.filename || 'File'}`;
|
|
858
|
+
}
|
|
859
|
+
if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
|
|
860
|
+
return '';
|
|
861
|
+
}
|
|
862
|
+
if (part.type === 'agent') {
|
|
863
|
+
return `ā¼ļø agent ${part.id}`;
|
|
864
|
+
}
|
|
865
|
+
if (part.type === 'snapshot') {
|
|
866
|
+
return `ā¼ļø snapshot ${part.snapshot}`;
|
|
867
|
+
}
|
|
868
|
+
if (part.type === 'tool') {
|
|
869
|
+
if (part.state.status !== 'completed' && part.state.status !== 'error') {
|
|
833
870
|
return '';
|
|
871
|
+
}
|
|
872
|
+
const summaryText = getToolSummaryText(part);
|
|
873
|
+
const outputToDisplay = getToolOutputToDisplay(part);
|
|
874
|
+
let toolTitle = part.state.status === 'completed' ? part.state.title || '' : 'error';
|
|
875
|
+
if (toolTitle) {
|
|
876
|
+
toolTitle = `\`${escapeInlineCode(toolTitle)}\``;
|
|
877
|
+
}
|
|
878
|
+
const icon = part.state.status === 'completed' ? 'ā¼ļø' : part.state.status === 'error' ? '⨯' : '';
|
|
879
|
+
const title = `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
880
|
+
if (outputToDisplay) {
|
|
881
|
+
return title + '\n\n' + outputToDisplay;
|
|
882
|
+
}
|
|
883
|
+
return title;
|
|
834
884
|
}
|
|
885
|
+
discordLogger.warn('Unknown part type:', part);
|
|
886
|
+
return '';
|
|
835
887
|
}
|
|
836
888
|
export async function createDiscordClient() {
|
|
837
889
|
return new Client({
|
|
@@ -868,6 +920,9 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
868
920
|
sessionLogger.log(`Using directory: ${directory}`);
|
|
869
921
|
// Note: We'll cancel the existing request after we have the session ID
|
|
870
922
|
const getClient = await initializeOpencodeForDirectory(directory);
|
|
923
|
+
// Get the port for this directory
|
|
924
|
+
const serverEntry = opencodeServers.get(directory);
|
|
925
|
+
const port = serverEntry?.port;
|
|
871
926
|
// Get session ID from database
|
|
872
927
|
const row = getDatabase()
|
|
873
928
|
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
@@ -939,6 +994,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
939
994
|
}
|
|
940
995
|
let currentParts = [];
|
|
941
996
|
let stopTyping = null;
|
|
997
|
+
let usedModel;
|
|
942
998
|
const sendPartMessage = async (part) => {
|
|
943
999
|
const content = formatPart(part) + '\n\n';
|
|
944
1000
|
if (!content.trim() || content.length === 0) {
|
|
@@ -1023,6 +1079,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1023
1079
|
// Track assistant message ID
|
|
1024
1080
|
if (msg.role === 'assistant') {
|
|
1025
1081
|
assistantMessageId = msg.id;
|
|
1082
|
+
usedModel = msg.modelID;
|
|
1026
1083
|
voiceLogger.log(`[EVENT] Tracking assistant message ${assistantMessageId}`);
|
|
1027
1084
|
}
|
|
1028
1085
|
else {
|
|
@@ -1142,8 +1199,10 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1142
1199
|
if (!abortController.signal.aborted ||
|
|
1143
1200
|
abortController.signal.reason === 'finished') {
|
|
1144
1201
|
const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
|
|
1145
|
-
|
|
1146
|
-
|
|
1202
|
+
const attachCommand = port ? ` ā
${session.id}` : '';
|
|
1203
|
+
const modelInfo = usedModel ? ` ā
${usedModel}` : '';
|
|
1204
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}_${attachCommand}${modelInfo}`);
|
|
1205
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}`);
|
|
1147
1206
|
}
|
|
1148
1207
|
else {
|
|
1149
1208
|
sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
|
|
@@ -1161,9 +1220,8 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1161
1220
|
},
|
|
1162
1221
|
signal: abortController.signal,
|
|
1163
1222
|
});
|
|
1164
|
-
abortController.abort(
|
|
1223
|
+
abortController.abort('finished');
|
|
1165
1224
|
sessionLogger.log(`Successfully sent prompt, got response`);
|
|
1166
|
-
abortControllers.delete(session.id);
|
|
1167
1225
|
// Update reaction to success
|
|
1168
1226
|
if (originalMessage) {
|
|
1169
1227
|
try {
|
|
@@ -1175,12 +1233,12 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1175
1233
|
discordLogger.log(`Could not update reaction:`, e);
|
|
1176
1234
|
}
|
|
1177
1235
|
}
|
|
1178
|
-
return { sessionID: session.id, result: response.data };
|
|
1236
|
+
return { sessionID: session.id, result: response.data, port };
|
|
1179
1237
|
}
|
|
1180
1238
|
catch (error) {
|
|
1181
1239
|
sessionLogger.error(`ERROR: Failed to send prompt:`, error);
|
|
1182
1240
|
if (!isAbortError(error, abortController.signal)) {
|
|
1183
|
-
abortController.abort(
|
|
1241
|
+
abortController.abort('error');
|
|
1184
1242
|
if (originalMessage) {
|
|
1185
1243
|
try {
|
|
1186
1244
|
await originalMessage.reactions.removeAll();
|
|
@@ -1191,7 +1249,6 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
|
|
|
1191
1249
|
discordLogger.log(`Could not update reaction:`, e);
|
|
1192
1250
|
}
|
|
1193
1251
|
}
|
|
1194
|
-
// Always log the error's constructor name (if any) and make error reporting more readable
|
|
1195
1252
|
const errorName = error &&
|
|
1196
1253
|
typeof error === 'object' &&
|
|
1197
1254
|
'constructor' in error &&
|
|
@@ -1466,10 +1523,20 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1466
1523
|
.toLowerCase()
|
|
1467
1524
|
.includes(focusedValue.toLowerCase()))
|
|
1468
1525
|
.slice(0, 25) // Discord limit
|
|
1469
|
-
.map((session) =>
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1526
|
+
.map((session) => {
|
|
1527
|
+
const dateStr = new Date(session.time.updated).toLocaleString();
|
|
1528
|
+
const suffix = ` (${dateStr})`;
|
|
1529
|
+
// Discord limit is 100 chars. Reserve space for suffix.
|
|
1530
|
+
const maxTitleLength = 100 - suffix.length;
|
|
1531
|
+
let title = session.title;
|
|
1532
|
+
if (title.length > maxTitleLength) {
|
|
1533
|
+
title = title.slice(0, Math.max(0, maxTitleLength - 1)) + 'ā¦';
|
|
1534
|
+
}
|
|
1535
|
+
return {
|
|
1536
|
+
name: `${title}${suffix}`,
|
|
1537
|
+
value: session.id,
|
|
1538
|
+
};
|
|
1539
|
+
});
|
|
1473
1540
|
await interaction.respond(sessions);
|
|
1474
1541
|
}
|
|
1475
1542
|
catch (error) {
|
|
@@ -1523,7 +1590,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1523
1590
|
: '';
|
|
1524
1591
|
// Map to Discord autocomplete format
|
|
1525
1592
|
const choices = files
|
|
1526
|
-
.slice(0, 25) // Discord limit
|
|
1527
1593
|
.map((file) => {
|
|
1528
1594
|
const fullValue = prefix + file;
|
|
1529
1595
|
// Get all basenames for display
|
|
@@ -1538,7 +1604,10 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1538
1604
|
name: displayName,
|
|
1539
1605
|
value: fullValue,
|
|
1540
1606
|
};
|
|
1541
|
-
})
|
|
1607
|
+
})
|
|
1608
|
+
// Discord API limits choice value to 100 characters
|
|
1609
|
+
.filter((choice) => choice.value.length <= 100)
|
|
1610
|
+
.slice(0, 25); // Discord limit
|
|
1542
1611
|
await interaction.respond(choices);
|
|
1543
1612
|
}
|
|
1544
1613
|
catch (error) {
|
|
@@ -1547,6 +1616,48 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1547
1616
|
}
|
|
1548
1617
|
}
|
|
1549
1618
|
}
|
|
1619
|
+
else if (interaction.commandName === 'add-project') {
|
|
1620
|
+
const focusedValue = interaction.options.getFocused();
|
|
1621
|
+
try {
|
|
1622
|
+
const currentDir = process.cwd();
|
|
1623
|
+
const getClient = await initializeOpencodeForDirectory(currentDir);
|
|
1624
|
+
const projectsResponse = await getClient().project.list({});
|
|
1625
|
+
if (!projectsResponse.data) {
|
|
1626
|
+
await interaction.respond([]);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
const db = getDatabase();
|
|
1630
|
+
const existingDirs = db
|
|
1631
|
+
.prepare('SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?')
|
|
1632
|
+
.all('text');
|
|
1633
|
+
const existingDirSet = new Set(existingDirs.map((row) => row.directory));
|
|
1634
|
+
const availableProjects = projectsResponse.data.filter((project) => !existingDirSet.has(project.worktree));
|
|
1635
|
+
const projects = availableProjects
|
|
1636
|
+
.filter((project) => {
|
|
1637
|
+
const baseName = path.basename(project.worktree);
|
|
1638
|
+
const searchText = `${baseName} ${project.worktree}`.toLowerCase();
|
|
1639
|
+
return searchText.includes(focusedValue.toLowerCase());
|
|
1640
|
+
})
|
|
1641
|
+
.sort((a, b) => {
|
|
1642
|
+
const aTime = a.time.initialized || a.time.created;
|
|
1643
|
+
const bTime = b.time.initialized || b.time.created;
|
|
1644
|
+
return bTime - aTime;
|
|
1645
|
+
})
|
|
1646
|
+
.slice(0, 25)
|
|
1647
|
+
.map((project) => {
|
|
1648
|
+
const name = `${path.basename(project.worktree)} (${project.worktree})`;
|
|
1649
|
+
return {
|
|
1650
|
+
name: name.length > 100 ? name.slice(0, 99) + 'ā¦' : name,
|
|
1651
|
+
value: project.id,
|
|
1652
|
+
};
|
|
1653
|
+
});
|
|
1654
|
+
await interaction.respond(projects);
|
|
1655
|
+
}
|
|
1656
|
+
catch (error) {
|
|
1657
|
+
voiceLogger.error('[AUTOCOMPLETE] Error fetching projects:', error);
|
|
1658
|
+
await interaction.respond([]);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1550
1661
|
}
|
|
1551
1662
|
// Handle slash commands
|
|
1552
1663
|
if (interaction.isChatInputCommand()) {
|
|
@@ -1727,6 +1838,53 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
1727
1838
|
await command.editReply(`Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
1728
1839
|
}
|
|
1729
1840
|
}
|
|
1841
|
+
else if (command.commandName === 'add-project') {
|
|
1842
|
+
await command.deferReply({ ephemeral: false });
|
|
1843
|
+
const projectId = command.options.getString('project', true);
|
|
1844
|
+
const guild = command.guild;
|
|
1845
|
+
if (!guild) {
|
|
1846
|
+
await command.editReply('This command can only be used in a guild');
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
try {
|
|
1850
|
+
const currentDir = process.cwd();
|
|
1851
|
+
const getClient = await initializeOpencodeForDirectory(currentDir);
|
|
1852
|
+
const projectsResponse = await getClient().project.list({});
|
|
1853
|
+
if (!projectsResponse.data) {
|
|
1854
|
+
await command.editReply('Failed to fetch projects');
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
const project = projectsResponse.data.find((p) => p.id === projectId);
|
|
1858
|
+
if (!project) {
|
|
1859
|
+
await command.editReply('Project not found');
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
const directory = project.worktree;
|
|
1863
|
+
if (!fs.existsSync(directory)) {
|
|
1864
|
+
await command.editReply(`Directory does not exist: ${directory}`);
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const db = getDatabase();
|
|
1868
|
+
const existingChannel = db
|
|
1869
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?')
|
|
1870
|
+
.get(directory, 'text');
|
|
1871
|
+
if (existingChannel) {
|
|
1872
|
+
await command.editReply(`A channel already exists for this directory: <#${existingChannel.channel_id}>`);
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
1876
|
+
guild,
|
|
1877
|
+
projectDirectory: directory,
|
|
1878
|
+
appId: currentAppId,
|
|
1879
|
+
});
|
|
1880
|
+
await command.editReply(`ā
Created channels for project:\nš Text: <#${textChannelId}>\nš Voice: <#${voiceChannelId}>\nš Directory: \`${directory}\``);
|
|
1881
|
+
discordLogger.log(`Created channels for project ${channelName} at ${directory}`);
|
|
1882
|
+
}
|
|
1883
|
+
catch (error) {
|
|
1884
|
+
voiceLogger.error('[ADD-PROJECT] Error:', error);
|
|
1885
|
+
await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1730
1888
|
}
|
|
1731
1889
|
}
|
|
1732
1890
|
catch (error) {
|