kimaki 0.4.1 → 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.
@@ -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
  }
711
- function formatPart(part) {
712
- switch (part.type) {
713
- case 'text':
714
- return escapeDiscordFormatting(part.text || '');
715
- case 'reasoning':
716
- if (!part.text?.trim())
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;
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 = 'ā—';
819
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
+ }
847
+ function formatPart(part) {
848
+ if (part.type === 'text') {
849
+ return part.text || '';
850
+ }
851
+ if (part.type === 'reasoning') {
852
+ if (!part.text?.trim())
820
853
  return '';
821
- case 'file':
822
- return `šŸ“„ ${part.filename || 'File'}`;
823
- case 'step-start':
824
- case 'step-finish':
825
- case 'patch':
826
- return '';
827
- case 'agent':
828
- return `ā—¼ļøŽ agent ${part.id}`;
829
- case 'snapshot':
830
- return `ā—¼ļøŽ snapshot ${part.snapshot}`;
831
- default:
832
- discordLogger.warn('Unknown part type:', part);
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 = ?')
@@ -912,9 +967,12 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
912
967
  if (abortControllers.has(session.id)) {
913
968
  abortControllers.get(session.id)?.abort(new Error('new reply'));
914
969
  }
915
- const promptAbortController = new AbortController();
916
- abortControllers.set(session.id, promptAbortController);
917
- const eventsResult = await getClient().event.subscribe({});
970
+ const abortController = new AbortController();
971
+ // Store this controller for this session
972
+ abortControllers.set(session.id, abortController);
973
+ const eventsResult = await getClient().event.subscribe({
974
+ signal: abortController.signal,
975
+ });
918
976
  const events = eventsResult.stream;
919
977
  sessionLogger.log(`Subscribed to OpenCode events`);
920
978
  // Load existing part-message mappings from database
@@ -936,6 +994,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
936
994
  }
937
995
  let currentParts = [];
938
996
  let stopTyping = null;
997
+ let usedModel;
939
998
  const sendPartMessage = async (part) => {
940
999
  const content = formatPart(part) + '\n\n';
941
1000
  if (!content.trim() || content.length === 0) {
@@ -966,7 +1025,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
966
1025
  // Outer-scoped interval for typing notifications. Only one at a time.
967
1026
  let typingInterval = null;
968
1027
  function startTyping(thread) {
969
- if (promptAbortController.signal.aborted) {
1028
+ if (abortController.signal.aborted) {
970
1029
  discordLogger.log(`Not starting typing, already aborted`);
971
1030
  return () => { };
972
1031
  }
@@ -988,8 +1047,8 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
988
1047
  });
989
1048
  }, 8000);
990
1049
  // Only add listener if not already aborted
991
- if (!promptAbortController.signal.aborted) {
992
- promptAbortController.signal.addEventListener('abort', () => {
1050
+ if (!abortController.signal.aborted) {
1051
+ abortController.signal.addEventListener('abort', () => {
993
1052
  if (typingInterval) {
994
1053
  clearInterval(typingInterval);
995
1054
  typingInterval = null;
@@ -1020,6 +1079,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1020
1079
  // Track assistant message ID
1021
1080
  if (msg.role === 'assistant') {
1022
1081
  assistantMessageId = msg.id;
1082
+ usedModel = msg.modelID;
1023
1083
  voiceLogger.log(`[EVENT] Tracking assistant message ${assistantMessageId}`);
1024
1084
  }
1025
1085
  else {
@@ -1061,7 +1121,7 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1061
1121
  }
1062
1122
  // start typing in a moment, so that if the session finished, because step-finish is at the end of the message, we do not show typing status
1063
1123
  setTimeout(() => {
1064
- if (promptAbortController.signal.aborted)
1124
+ if (abortController.signal.aborted)
1065
1125
  return;
1066
1126
  stopTyping = startTyping(thread);
1067
1127
  }, 300);
@@ -1100,6 +1160,10 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1100
1160
  }
1101
1161
  }
1102
1162
  catch (e) {
1163
+ if (isAbortError(e, abortController.signal)) {
1164
+ sessionLogger.log('AbortController aborted event handling (normal exit)');
1165
+ return;
1166
+ }
1103
1167
  sessionLogger.error(`Unexpected error in event handling code`, e);
1104
1168
  throw e;
1105
1169
  }
@@ -1132,14 +1196,16 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1132
1196
  sessionLogger.log(`Stopped typing for session`);
1133
1197
  }
1134
1198
  // Only send duration message if request was not aborted or was aborted with 'finished' reason
1135
- if (!promptAbortController.signal.aborted ||
1136
- promptAbortController.signal.reason === 'finished') {
1199
+ if (!abortController.signal.aborted ||
1200
+ abortController.signal.reason === 'finished') {
1137
1201
  const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
1138
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}_`);
1139
- sessionLogger.log(`DURATION: Session completed in ${sessionDuration}`);
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}`);
1140
1206
  }
1141
1207
  else {
1142
- sessionLogger.log(`Session was aborted (reason: ${promptAbortController.signal.reason}), skipping duration message`);
1208
+ sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
1143
1209
  }
1144
1210
  }
1145
1211
  };
@@ -1152,11 +1218,10 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1152
1218
  body: {
1153
1219
  parts: [{ type: 'text', text: prompt }],
1154
1220
  },
1155
- signal: promptAbortController.signal,
1221
+ signal: abortController.signal,
1156
1222
  });
1157
- promptAbortController.abort(new Error('finished'));
1223
+ abortController.abort('finished');
1158
1224
  sessionLogger.log(`Successfully sent prompt, got response`);
1159
- abortControllers.delete(session.id);
1160
1225
  // Update reaction to success
1161
1226
  if (originalMessage) {
1162
1227
  try {
@@ -1168,12 +1233,12 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1168
1233
  discordLogger.log(`Could not update reaction:`, e);
1169
1234
  }
1170
1235
  }
1171
- return { sessionID: session.id, result: response.data };
1236
+ return { sessionID: session.id, result: response.data, port };
1172
1237
  }
1173
1238
  catch (error) {
1174
1239
  sessionLogger.error(`ERROR: Failed to send prompt:`, error);
1175
- if (!isAbortError(error, promptAbortController.signal)) {
1176
- promptAbortController.abort(new Error('error'));
1240
+ if (!isAbortError(error, abortController.signal)) {
1241
+ abortController.abort('error');
1177
1242
  if (originalMessage) {
1178
1243
  try {
1179
1244
  await originalMessage.reactions.removeAll();
@@ -1184,7 +1249,6 @@ async function handleOpencodeSession(prompt, thread, projectDirectory, originalM
1184
1249
  discordLogger.log(`Could not update reaction:`, e);
1185
1250
  }
1186
1251
  }
1187
- // Always log the error's constructor name (if any) and make error reporting more readable
1188
1252
  const errorName = error &&
1189
1253
  typeof error === 'object' &&
1190
1254
  'constructor' in error &&
@@ -1459,10 +1523,20 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1459
1523
  .toLowerCase()
1460
1524
  .includes(focusedValue.toLowerCase()))
1461
1525
  .slice(0, 25) // Discord limit
1462
- .map((session) => ({
1463
- name: `${session.title} (${new Date(session.time.updated).toLocaleString()})`,
1464
- value: session.id,
1465
- }));
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
+ });
1466
1540
  await interaction.respond(sessions);
1467
1541
  }
1468
1542
  catch (error) {
@@ -1516,7 +1590,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1516
1590
  : '';
1517
1591
  // Map to Discord autocomplete format
1518
1592
  const choices = files
1519
- .slice(0, 25) // Discord limit
1520
1593
  .map((file) => {
1521
1594
  const fullValue = prefix + file;
1522
1595
  // Get all basenames for display
@@ -1531,7 +1604,10 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1531
1604
  name: displayName,
1532
1605
  value: fullValue,
1533
1606
  };
1534
- });
1607
+ })
1608
+ // Discord API limits choice value to 100 characters
1609
+ .filter((choice) => choice.value.length <= 100)
1610
+ .slice(0, 25); // Discord limit
1535
1611
  await interaction.respond(choices);
1536
1612
  }
1537
1613
  catch (error) {
@@ -1540,6 +1616,48 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1540
1616
  }
1541
1617
  }
1542
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
+ }
1543
1661
  }
1544
1662
  // Handle slash commands
1545
1663
  if (interaction.isChatInputCommand()) {
@@ -1720,6 +1838,53 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1720
1838
  await command.editReply(`Failed to resume session: ${error instanceof Error ? error.message : 'Unknown error'}`);
1721
1839
  }
1722
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
+ }
1723
1888
  }
1724
1889
  }
1725
1890
  catch (error) {