kimaki 0.4.35 → 0.4.36

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.
Files changed (75) hide show
  1. package/dist/ai-tool-to-genai.js +1 -3
  2. package/dist/channel-management.js +1 -1
  3. package/dist/cli.js +135 -39
  4. package/dist/commands/abort.js +1 -1
  5. package/dist/commands/add-project.js +1 -1
  6. package/dist/commands/agent.js +6 -2
  7. package/dist/commands/ask-question.js +2 -1
  8. package/dist/commands/fork.js +7 -7
  9. package/dist/commands/queue.js +2 -2
  10. package/dist/commands/remove-project.js +109 -0
  11. package/dist/commands/resume.js +3 -5
  12. package/dist/commands/session.js +2 -2
  13. package/dist/commands/share.js +1 -1
  14. package/dist/commands/undo-redo.js +2 -2
  15. package/dist/commands/user-command.js +3 -6
  16. package/dist/config.js +1 -1
  17. package/dist/discord-bot.js +4 -10
  18. package/dist/discord-utils.js +33 -9
  19. package/dist/genai.js +4 -6
  20. package/dist/interaction-handler.js +8 -1
  21. package/dist/markdown.js +1 -3
  22. package/dist/message-formatting.js +7 -3
  23. package/dist/openai-realtime.js +3 -5
  24. package/dist/opencode.js +1 -1
  25. package/dist/session-handler.js +25 -15
  26. package/dist/system-message.js +5 -3
  27. package/dist/tools.js +9 -22
  28. package/dist/voice-handler.js +9 -12
  29. package/dist/voice.js +5 -3
  30. package/dist/xml.js +2 -4
  31. package/package.json +3 -2
  32. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  33. package/src/__snapshots__/compact-session-context.md +31 -31
  34. package/src/ai-tool-to-genai.ts +3 -11
  35. package/src/channel-management.ts +14 -25
  36. package/src/cli.ts +282 -195
  37. package/src/commands/abort.ts +1 -3
  38. package/src/commands/add-project.ts +8 -14
  39. package/src/commands/agent.ts +16 -9
  40. package/src/commands/ask-question.ts +8 -7
  41. package/src/commands/create-new-project.ts +8 -14
  42. package/src/commands/fork.ts +23 -27
  43. package/src/commands/model.ts +14 -11
  44. package/src/commands/permissions.ts +1 -1
  45. package/src/commands/queue.ts +6 -19
  46. package/src/commands/remove-project.ts +136 -0
  47. package/src/commands/resume.ts +11 -30
  48. package/src/commands/session.ts +4 -13
  49. package/src/commands/share.ts +1 -3
  50. package/src/commands/types.ts +1 -3
  51. package/src/commands/undo-redo.ts +6 -18
  52. package/src/commands/user-command.ts +8 -10
  53. package/src/config.ts +5 -5
  54. package/src/database.ts +10 -8
  55. package/src/discord-bot.ts +22 -46
  56. package/src/discord-utils.ts +35 -18
  57. package/src/escape-backticks.test.ts +0 -2
  58. package/src/format-tables.ts +1 -4
  59. package/src/genai-worker-wrapper.ts +3 -9
  60. package/src/genai-worker.ts +4 -19
  61. package/src/genai.ts +10 -42
  62. package/src/interaction-handler.ts +133 -121
  63. package/src/markdown.test.ts +10 -32
  64. package/src/markdown.ts +6 -14
  65. package/src/message-formatting.ts +13 -14
  66. package/src/openai-realtime.ts +25 -47
  67. package/src/opencode.ts +24 -34
  68. package/src/session-handler.ts +91 -61
  69. package/src/system-message.ts +13 -3
  70. package/src/tools.ts +13 -39
  71. package/src/utils.ts +1 -4
  72. package/src/voice-handler.ts +34 -78
  73. package/src/voice.ts +11 -19
  74. package/src/xml.test.ts +1 -1
  75. package/src/xml.ts +3 -12
@@ -150,9 +150,7 @@ export function aiToolToCallableTool(tool, name) {
150
150
  const parts = [];
151
151
  for (const functionCall of functionCalls) {
152
152
  // Check if this function call matches our tool
153
- if (functionCall.name !== toolName &&
154
- name &&
155
- functionCall.name !== name) {
153
+ if (functionCall.name !== toolName && name && functionCall.name !== name) {
156
154
  continue;
157
155
  }
158
156
  // Execute the tool if it has an execute function
@@ -1,7 +1,7 @@
1
1
  // Discord channel and category management.
2
2
  // Creates and manages Kimaki project channels (text + voice pairs),
3
3
  // extracts channel metadata from topic tags, and ensures category structure.
4
- import { ChannelType, } from 'discord.js';
4
+ import { ChannelType } from 'discord.js';
5
5
  import path from 'node:path';
6
6
  import { getDatabase } from './database.js';
7
7
  import { extractTagsArrays } from './xml.js';
package/dist/cli.js CHANGED
@@ -23,11 +23,18 @@ async function killProcessOnPort(port) {
23
23
  try {
24
24
  if (isWindows) {
25
25
  // Windows: find PID using netstat, then kill
26
- const result = spawnSync('cmd', ['/c', `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`], {
26
+ const result = spawnSync('cmd', [
27
+ '/c',
28
+ `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`,
29
+ ], {
27
30
  shell: false,
28
31
  encoding: 'utf-8',
29
32
  });
30
- const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p));
33
+ const pids = result.stdout
34
+ ?.trim()
35
+ .split('\n')
36
+ .map((p) => p.trim())
37
+ .filter((p) => /^\d+$/.test(p));
31
38
  // Filter out our own PID and take the first (oldest)
32
39
  const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
33
40
  if (targetPid) {
@@ -42,7 +49,11 @@ async function killProcessOnPort(port) {
42
49
  shell: false,
43
50
  encoding: 'utf-8',
44
51
  });
45
- const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p));
52
+ const pids = result.stdout
53
+ ?.trim()
54
+ .split('\n')
55
+ .map((p) => p.trim())
56
+ .filter((p) => /^\d+$/.test(p));
46
57
  // Filter out our own PID and take the first (oldest)
47
58
  const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
48
59
  if (targetPid) {
@@ -68,7 +79,9 @@ async function checkSingleInstance() {
68
79
  cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`);
69
80
  await killProcessOnPort(lockPort);
70
81
  // Wait a moment for port to be released
71
- await new Promise((resolve) => { setTimeout(resolve, 500); });
82
+ await new Promise((resolve) => {
83
+ setTimeout(resolve, 500);
84
+ });
72
85
  }
73
86
  }
74
87
  catch {
@@ -91,7 +104,9 @@ async function startLockServer() {
91
104
  if (err.code === 'EADDRINUSE') {
92
105
  cliLogger.log('Port still in use, retrying...');
93
106
  await killProcessOnPort(lockPort);
94
- await new Promise((r) => { setTimeout(r, 500); });
107
+ await new Promise((r) => {
108
+ setTimeout(r, 500);
109
+ });
95
110
  // Retry once
96
111
  server.listen(lockPort, '127.0.0.1');
97
112
  }
@@ -122,10 +137,7 @@ async function registerCommands(token, appId, userCommands = []) {
122
137
  .setName('session')
123
138
  .setDescription('Start a new OpenCode session')
124
139
  .addStringOption((option) => {
125
- option
126
- .setName('prompt')
127
- .setDescription('Prompt content for the session')
128
- .setRequired(true);
140
+ option.setName('prompt').setDescription('Prompt content for the session').setRequired(true);
129
141
  return option;
130
142
  })
131
143
  .addStringOption((option) => {
@@ -156,14 +168,23 @@ async function registerCommands(token, appId, userCommands = []) {
156
168
  return option;
157
169
  })
158
170
  .toJSON(),
171
+ new SlashCommandBuilder()
172
+ .setName('remove-project')
173
+ .setDescription('Remove Discord channels for a project')
174
+ .addStringOption((option) => {
175
+ option
176
+ .setName('project')
177
+ .setDescription('Select a project to remove')
178
+ .setRequired(true)
179
+ .setAutocomplete(true);
180
+ return option;
181
+ })
182
+ .toJSON(),
159
183
  new SlashCommandBuilder()
160
184
  .setName('create-new-project')
161
185
  .setDescription('Create a new project folder, initialize git, and start a session')
162
186
  .addStringOption((option) => {
163
- option
164
- .setName('name')
165
- .setDescription('Name for the new project folder')
166
- .setRequired(true);
187
+ option.setName('name').setDescription('Name for the new project folder').setRequired(true);
167
188
  return option;
168
189
  })
169
190
  .toJSON(),
@@ -195,10 +216,7 @@ async function registerCommands(token, appId, userCommands = []) {
195
216
  .setName('queue')
196
217
  .setDescription('Queue a message to be sent after the current response finishes')
197
218
  .addStringOption((option) => {
198
- option
199
- .setName('message')
200
- .setDescription('The message to queue')
201
- .setRequired(true);
219
+ option.setName('message').setDescription('The message to queue').setRequired(true);
202
220
  return option;
203
221
  })
204
222
  .toJSON(),
@@ -225,7 +243,7 @@ async function registerCommands(token, appId, userCommands = []) {
225
243
  const commandName = `${sanitizedName}-cmd`;
226
244
  const description = cmd.description || `Run /${cmd.name} command`;
227
245
  commands.push(new SlashCommandBuilder()
228
- .setName(commandName)
246
+ .setName(commandName.slice(0, 32)) // Discord limits to 32 chars
229
247
  .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
230
248
  .addStringOption((option) => {
231
249
  option
@@ -477,11 +495,7 @@ async function run({ restart, addChannels }) {
477
495
  if (kimakiChannels.length > 0) {
478
496
  const channelList = kimakiChannels
479
497
  .flatMap(({ guild, channels }) => channels.map((ch) => {
480
- const appInfo = ch.kimakiApp === appId
481
- ? ' (this bot)'
482
- : ch.kimakiApp
483
- ? ` (app: ${ch.kimakiApp})`
484
- : '';
498
+ const appInfo = ch.kimakiApp === appId ? ' (this bot)' : ch.kimakiApp ? ` (app: ${ch.kimakiApp})` : '';
485
499
  return `#${ch.name} in ${guild.name}: ${ch.kimakiDirectory}${appInfo}`;
486
500
  }))
487
501
  .join('\n');
@@ -494,13 +508,19 @@ async function run({ restart, addChannels }) {
494
508
  s.start('Fetching OpenCode data...');
495
509
  // Fetch projects and commands in parallel
496
510
  const [projects, allUserCommands] = await Promise.all([
497
- getClient().project.list({}).then((r) => r.data || []).catch((error) => {
511
+ getClient()
512
+ .project.list({})
513
+ .then((r) => r.data || [])
514
+ .catch((error) => {
498
515
  s.stop('Failed to fetch projects');
499
516
  cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
500
517
  discordClient.destroy();
501
518
  process.exit(EXIT_NO_RESTART);
502
519
  }),
503
- getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
520
+ getClient()
521
+ .command.list({ query: { directory: currentDir } })
522
+ .then((r) => r.data || [])
523
+ .catch(() => []),
504
524
  ]);
505
525
  s.stop(`Found ${projects.length} OpenCode project(s)`);
506
526
  const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
@@ -519,8 +539,7 @@ async function run({ restart, addChannels }) {
519
539
  if (availableProjects.length === 0) {
520
540
  note('All OpenCode projects already have Discord channels', 'No New Projects');
521
541
  }
522
- if ((!existingDirs?.length && availableProjects.length > 0) ||
523
- shouldAddChannels) {
542
+ if ((!existingDirs?.length && availableProjects.length > 0) || shouldAddChannels) {
524
543
  const selectedProjects = await multiselect({
525
544
  message: 'Select projects to create Discord channels for:',
526
545
  options: availableProjects.map((project) => ({
@@ -620,7 +639,8 @@ async function run({ restart, addChannels }) {
620
639
  .join('\n');
621
640
  note(`Your kimaki channels are ready! Click any link below to open in Discord:\n\n${channelLinks}\n\nSend a message in any channel to start using OpenCode!`, '🚀 Ready to Use');
622
641
  }
623
- outro(' Setup complete!');
642
+ note('Leave this process running to keep the bot active.\n\nIf you close this process or restart your machine, run `npx kimaki` again to start the bot.', '⚠️ Keep Running');
643
+ outro('✨ Setup complete! Listening for new messages... do not close this process.');
624
644
  }
625
645
  cli
626
646
  .command('', 'Set up and run the Kimaki Discord bot')
@@ -702,13 +722,13 @@ cli
702
722
  const buffer = fs.readFileSync(file);
703
723
  const formData = new FormData();
704
724
  formData.append('payload_json', JSON.stringify({
705
- attachments: [{ id: 0, filename: path.basename(file) }]
725
+ attachments: [{ id: 0, filename: path.basename(file) }],
706
726
  }));
707
727
  formData.append('files[0]', new Blob([buffer]), path.basename(file));
708
728
  const response = await fetch(`https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`, {
709
729
  method: 'POST',
710
730
  headers: {
711
- 'Authorization': `Bot ${botRow.token}`,
731
+ Authorization: `Bot ${botRow.token}`,
712
732
  },
713
733
  body: formData,
714
734
  });
@@ -732,14 +752,16 @@ const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
732
752
  cli
733
753
  .command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
734
754
  .option('-c, --channel <channelId>', 'Discord channel ID')
755
+ .option('-d, --project <path>', 'Project directory (alternative to --channel)')
735
756
  .option('-p, --prompt <prompt>', 'Initial prompt for the session')
736
757
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
737
758
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
738
759
  .action(async (options) => {
739
760
  try {
740
- const { channel: channelId, prompt, name, appId: optionAppId } = options;
741
- if (!channelId) {
742
- cliLogger.error('Channel ID is required. Use --channel <channelId>');
761
+ let { channel: channelId, prompt, name, appId: optionAppId } = options;
762
+ const { project: projectPath } = options;
763
+ if (!channelId && !projectPath) {
764
+ cliLogger.error('Either --channel or --project is required');
743
765
  process.exit(EXIT_NO_RESTART);
744
766
  }
745
767
  if (!prompt) {
@@ -788,11 +810,85 @@ cli
788
810
  process.exit(EXIT_NO_RESTART);
789
811
  }
790
812
  const s = spinner();
813
+ // If --project provided, resolve to channel ID
814
+ if (projectPath) {
815
+ const absolutePath = path.resolve(projectPath);
816
+ if (!fs.existsSync(absolutePath)) {
817
+ cliLogger.error(`Directory does not exist: ${absolutePath}`);
818
+ process.exit(EXIT_NO_RESTART);
819
+ }
820
+ s.start('Looking up channel for project...');
821
+ // Check if channel already exists for this directory
822
+ try {
823
+ const db = getDatabase();
824
+ const existingChannel = db
825
+ .prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?')
826
+ .get(absolutePath, 'text');
827
+ if (existingChannel) {
828
+ channelId = existingChannel.channel_id;
829
+ s.message(`Found existing channel: ${channelId}`);
830
+ }
831
+ else {
832
+ // Need to create a new channel
833
+ s.message('Creating new channel...');
834
+ if (!appId) {
835
+ s.stop('Missing app ID');
836
+ cliLogger.error('App ID is required to create channels. Use --app-id or run `kimaki` first.');
837
+ process.exit(EXIT_NO_RESTART);
838
+ }
839
+ const client = await createDiscordClient();
840
+ await new Promise((resolve, reject) => {
841
+ client.once(Events.ClientReady, () => {
842
+ resolve();
843
+ });
844
+ client.once(Events.Error, reject);
845
+ client.login(botToken);
846
+ });
847
+ // Get guild from existing channels or first available
848
+ const guild = await (async () => {
849
+ // Try to find a guild from existing channels
850
+ const existingChannelRow = db
851
+ .prepare('SELECT channel_id FROM channel_directories ORDER BY created_at DESC LIMIT 1')
852
+ .get();
853
+ if (existingChannelRow) {
854
+ try {
855
+ const ch = await client.channels.fetch(existingChannelRow.channel_id);
856
+ if (ch && 'guild' in ch && ch.guild) {
857
+ return ch.guild;
858
+ }
859
+ }
860
+ catch {
861
+ // Channel might be deleted, continue
862
+ }
863
+ }
864
+ // Fall back to first guild
865
+ const firstGuild = client.guilds.cache.first();
866
+ if (!firstGuild) {
867
+ throw new Error('No guild found. Add the bot to a server first.');
868
+ }
869
+ return firstGuild;
870
+ })();
871
+ const { textChannelId } = await createProjectChannels({
872
+ guild,
873
+ projectDirectory: absolutePath,
874
+ appId,
875
+ botName: client.user?.username,
876
+ });
877
+ channelId = textChannelId;
878
+ s.message(`Created channel: ${channelId}`);
879
+ client.destroy();
880
+ }
881
+ }
882
+ catch (e) {
883
+ s.stop('Failed to resolve project');
884
+ throw e;
885
+ }
886
+ }
791
887
  s.start('Fetching channel info...');
792
888
  // Get channel info to extract directory from topic
793
889
  const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
794
890
  headers: {
795
- 'Authorization': `Bot ${botToken}`,
891
+ Authorization: `Bot ${botToken}`,
796
892
  },
797
893
  });
798
894
  if (!channelResponse.ok) {
@@ -800,7 +896,7 @@ cli
800
896
  s.stop('Failed to fetch channel');
801
897
  throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
802
898
  }
803
- const channelData = await channelResponse.json();
899
+ const channelData = (await channelResponse.json());
804
900
  if (!channelData.topic) {
805
901
  s.stop('Channel has no topic');
806
902
  throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`);
@@ -826,7 +922,7 @@ cli
826
922
  const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
827
923
  method: 'POST',
828
924
  headers: {
829
- 'Authorization': `Bot ${botToken}`,
925
+ Authorization: `Bot ${botToken}`,
830
926
  'Content-Type': 'application/json',
831
927
  },
832
928
  body: JSON.stringify({
@@ -838,14 +934,14 @@ cli
838
934
  s.stop('Failed to create message');
839
935
  throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
840
936
  }
841
- const starterMessage = await starterMessageResponse.json();
937
+ const starterMessage = (await starterMessageResponse.json());
842
938
  s.message('Creating thread...');
843
939
  // Create thread from the message
844
940
  const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
845
941
  const threadResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`, {
846
942
  method: 'POST',
847
943
  headers: {
848
- 'Authorization': `Bot ${botToken}`,
944
+ Authorization: `Bot ${botToken}`,
849
945
  'Content-Type': 'application/json',
850
946
  },
851
947
  body: JSON.stringify({
@@ -858,7 +954,7 @@ cli
858
954
  s.stop('Failed to create thread');
859
955
  throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
860
956
  }
861
- const threadData = await threadResponse.json();
957
+ const threadData = (await threadResponse.json());
862
958
  s.stop('Thread created!');
863
959
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
864
960
  note(`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`, '✅ Thread Created');
@@ -6,7 +6,7 @@ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../
6
6
  import { abortControllers } from '../session-handler.js';
7
7
  import { createLogger } from '../logger.js';
8
8
  const logger = createLogger('ABORT');
9
- export async function handleAbortCommand({ command, }) {
9
+ export async function handleAbortCommand({ command }) {
10
10
  const channel = command.channel;
11
11
  if (!channel) {
12
12
  await command.reply({
@@ -7,7 +7,7 @@ import { createProjectChannels } from '../channel-management.js';
7
7
  import { createLogger } from '../logger.js';
8
8
  import { abbreviatePath } from '../utils.js';
9
9
  const logger = createLogger('ADD-PROJECT');
10
- export async function handleAddProjectCommand({ command, appId, }) {
10
+ export async function handleAddProjectCommand({ command, appId }) {
11
11
  await command.deferReply({ ephemeral: false });
12
12
  const projectId = command.options.getString('project', true);
13
13
  const guild = command.guild;
@@ -44,7 +44,9 @@ export async function handleAgentCommand({ interaction, appId, }) {
44
44
  targetChannelId = channel.id;
45
45
  }
46
46
  else {
47
- await interaction.editReply({ content: 'This command can only be used in text channels or threads' });
47
+ await interaction.editReply({
48
+ content: 'This command can only be used in text channels or threads',
49
+ });
48
50
  return;
49
51
  }
50
52
  if (channelAppId && channelAppId !== appId) {
@@ -52,7 +54,9 @@ export async function handleAgentCommand({ interaction, appId, }) {
52
54
  return;
53
55
  }
54
56
  if (!projectDirectory) {
55
- await interaction.editReply({ content: 'This channel is not configured with a project directory' });
57
+ await interaction.editReply({
58
+ content: 'This channel is not configured with a project directory',
59
+ });
56
60
  return;
57
61
  }
58
62
  try {
@@ -44,9 +44,10 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
44
44
  description: 'Provide a custom answer in chat',
45
45
  },
46
46
  ];
47
+ const placeholder = options.find((x) => x.label)?.label || 'Select an option';
47
48
  const selectMenu = new StringSelectMenuBuilder()
48
49
  .setCustomId(`ask_question:${contextHash}:${i}`)
49
- .setPlaceholder(`Select an option`)
50
+ .setPlaceholder(placeholder)
50
51
  .addOptions(options);
51
52
  // Enable multi-select if the question supports it
52
53
  if (q.multiple) {
@@ -84,8 +84,7 @@ export async function handleForkCommand(interaction) {
84
84
  .setCustomId(`fork_select:${sessionId}:${encodedDir}`)
85
85
  .setPlaceholder('Select a message to fork from')
86
86
  .addOptions(options);
87
- const actionRow = new ActionRowBuilder()
88
- .addComponents(selectMenu);
87
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
89
88
  await interaction.editReply({
90
89
  content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
91
90
  components: [actionRow],
@@ -133,11 +132,12 @@ export async function handleForkSelectMenu(interaction) {
133
132
  }
134
133
  const forkedSession = forkResponse.data;
135
134
  const parentChannel = interaction.channel;
136
- if (!parentChannel || ![
137
- ChannelType.PublicThread,
138
- ChannelType.PrivateThread,
139
- ChannelType.AnnouncementThread,
140
- ].includes(parentChannel.type)) {
135
+ if (!parentChannel ||
136
+ ![
137
+ ChannelType.PublicThread,
138
+ ChannelType.PrivateThread,
139
+ ChannelType.AnnouncementThread,
140
+ ].includes(parentChannel.type)) {
141
141
  await interaction.editReply('Could not access parent channel');
142
142
  return;
143
143
  }
@@ -5,7 +5,7 @@ import { resolveTextChannel, getKimakiMetadata, sendThreadMessage, SILENT_MESSAG
5
5
  import { handleOpencodeSession, abortControllers, addToQueue, getQueueLength, clearQueue, } from '../session-handler.js';
6
6
  import { createLogger } from '../logger.js';
7
7
  const logger = createLogger('QUEUE');
8
- export async function handleQueueCommand({ command, }) {
8
+ export async function handleQueueCommand({ command }) {
9
9
  const message = command.options.getString('message', true);
10
10
  const channel = command.channel;
11
11
  if (!channel) {
@@ -88,7 +88,7 @@ export async function handleQueueCommand({ command, }) {
88
88
  });
89
89
  logger.log(`[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`);
90
90
  }
91
- export async function handleClearQueueCommand({ command, }) {
91
+ export async function handleClearQueueCommand({ command }) {
92
92
  const channel = command.channel;
93
93
  if (!channel) {
94
94
  await command.reply({
@@ -0,0 +1,109 @@
1
+ // /remove-project command - Remove Discord channels for a project.
2
+ import path from 'node:path';
3
+ import { getDatabase } from '../database.js';
4
+ import { createLogger } from '../logger.js';
5
+ import { abbreviatePath } from '../utils.js';
6
+ const logger = createLogger('REMOVE-PROJECT');
7
+ export async function handleRemoveProjectCommand({ command, appId }) {
8
+ await command.deferReply({ ephemeral: false });
9
+ const directory = command.options.getString('project', true);
10
+ const guild = command.guild;
11
+ if (!guild) {
12
+ await command.editReply('This command can only be used in a guild');
13
+ return;
14
+ }
15
+ try {
16
+ const db = getDatabase();
17
+ // Get channel IDs for this directory
18
+ const channels = db
19
+ .prepare('SELECT channel_id, channel_type FROM channel_directories WHERE directory = ?')
20
+ .all(directory);
21
+ if (channels.length === 0) {
22
+ await command.editReply(`No channels found for directory: \`${directory}\``);
23
+ return;
24
+ }
25
+ const deletedChannels = [];
26
+ const failedChannels = [];
27
+ for (const { channel_id, channel_type } of channels) {
28
+ try {
29
+ const channel = await guild.channels.fetch(channel_id).catch(() => null);
30
+ if (channel) {
31
+ await channel.delete(`Removed by /remove-project command`);
32
+ deletedChannels.push(`${channel_type}: ${channel_id}`);
33
+ }
34
+ else {
35
+ // Channel doesn't exist in this guild or was already deleted
36
+ deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`);
37
+ }
38
+ }
39
+ catch (error) {
40
+ logger.error(`Failed to delete channel ${channel_id}:`, error);
41
+ failedChannels.push(`${channel_type}: ${channel_id}`);
42
+ }
43
+ }
44
+ // Remove from database
45
+ db.prepare('DELETE FROM channel_directories WHERE directory = ?').run(directory);
46
+ const projectName = path.basename(directory);
47
+ let message = `Removed project **${projectName}**\n`;
48
+ message += `Directory: \`${directory}\`\n\n`;
49
+ if (deletedChannels.length > 0) {
50
+ message += `Deleted channels:\n${deletedChannels.map((c) => `- ${c}`).join('\n')}`;
51
+ }
52
+ if (failedChannels.length > 0) {
53
+ message += `\n\nFailed to delete (may be in another server):\n${failedChannels.map((c) => `- ${c}`).join('\n')}`;
54
+ }
55
+ await command.editReply(message);
56
+ logger.log(`Removed project ${projectName} at ${directory}`);
57
+ }
58
+ catch (error) {
59
+ logger.error('[REMOVE-PROJECT] Error:', error);
60
+ await command.editReply(`Failed to remove project: ${error instanceof Error ? error.message : 'Unknown error'}`);
61
+ }
62
+ }
63
+ export async function handleRemoveProjectAutocomplete({ interaction, appId, }) {
64
+ const focusedValue = interaction.options.getFocused();
65
+ const guild = interaction.guild;
66
+ if (!guild) {
67
+ await interaction.respond([]);
68
+ return;
69
+ }
70
+ try {
71
+ const db = getDatabase();
72
+ // Get all directories with channels
73
+ const allChannels = db
74
+ .prepare('SELECT DISTINCT directory, channel_id FROM channel_directories WHERE channel_type = ?')
75
+ .all('text');
76
+ // Filter to only channels that exist in this guild
77
+ const projectsInGuild = [];
78
+ for (const { directory, channel_id } of allChannels) {
79
+ try {
80
+ const channel = await guild.channels.fetch(channel_id).catch(() => null);
81
+ if (channel) {
82
+ projectsInGuild.push({ directory, channelId: channel_id });
83
+ }
84
+ }
85
+ catch {
86
+ // Channel not in this guild, skip
87
+ }
88
+ }
89
+ const projects = projectsInGuild
90
+ .filter(({ directory }) => {
91
+ const baseName = path.basename(directory);
92
+ const searchText = `${baseName} ${directory}`.toLowerCase();
93
+ return searchText.includes(focusedValue.toLowerCase());
94
+ })
95
+ .slice(0, 25)
96
+ .map(({ directory }) => {
97
+ const name = `${path.basename(directory)} (${abbreviatePath(directory)})`;
98
+ return {
99
+ name: name.length > 100 ? name.slice(0, 99) + '...' : name,
100
+ value: directory,
101
+ };
102
+ });
103
+ await interaction.respond(projects);
104
+ }
105
+ catch (error) {
106
+ logger.error('[AUTOCOMPLETE] Error fetching projects:', error);
107
+ await interaction.respond([]);
108
+ }
109
+ }
@@ -3,12 +3,12 @@ import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
3
3
  import fs from 'node:fs';
4
4
  import { getDatabase } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
- import { sendThreadMessage, resolveTextChannel, getKimakiMetadata, } from '../discord-utils.js';
6
+ import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
7
  import { extractTagsArrays } from '../xml.js';
8
8
  import { collectLastAssistantParts } from '../message-formatting.js';
9
9
  import { createLogger } from '../logger.js';
10
10
  const logger = createLogger('RESUME');
11
- export async function handleResumeCommand({ command, appId, }) {
11
+ export async function handleResumeCommand({ command, appId }) {
12
12
  await command.deferReply({ ephemeral: false });
13
13
  const sessionId = command.options.getString('session', true);
14
14
  const channel = command.channel;
@@ -116,9 +116,7 @@ export async function handleResumeAutocomplete({ interaction, appId, }) {
116
116
  await interaction.respond([]);
117
117
  return;
118
118
  }
119
- const existingSessionIds = new Set(getDatabase()
120
- .prepare('SELECT session_id FROM thread_sessions')
121
- .all().map((row) => row.session_id));
119
+ const existingSessionIds = new Set(getDatabase().prepare('SELECT session_id FROM thread_sessions').all().map((row) => row.session_id));
122
120
  const sessions = sessionsResponse.data
123
121
  .filter((session) => !existingSessionIds.has(session.id))
124
122
  .filter((session) => session.title.toLowerCase().includes(focusedValue.toLowerCase()))
@@ -9,7 +9,7 @@ import { extractTagsArrays } from '../xml.js';
9
9
  import { handleOpencodeSession } from '../session-handler.js';
10
10
  import { createLogger } from '../logger.js';
11
11
  const logger = createLogger('SESSION');
12
- export async function handleSessionCommand({ command, appId, }) {
12
+ export async function handleSessionCommand({ command, appId }) {
13
13
  await command.deferReply({ ephemeral: false });
14
14
  const prompt = command.options.getString('prompt', true);
15
15
  const filesString = command.options.getString('files') || '';
@@ -75,7 +75,7 @@ export async function handleSessionCommand({ command, appId, }) {
75
75
  await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
76
76
  }
77
77
  }
78
- async function handleAgentAutocomplete({ interaction, appId, }) {
78
+ async function handleAgentAutocomplete({ interaction, appId }) {
79
79
  const focusedValue = interaction.options.getFocused();
80
80
  let projectDirectory;
81
81
  if (interaction.channel) {
@@ -5,7 +5,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger } from '../logger.js';
7
7
  const logger = createLogger('SHARE');
8
- export async function handleShareCommand({ command, }) {
8
+ export async function handleShareCommand({ command }) {
9
9
  const channel = command.channel;
10
10
  if (!channel) {
11
11
  await command.reply({
@@ -5,7 +5,7 @@ import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { createLogger } from '../logger.js';
7
7
  const logger = createLogger('UNDO-REDO');
8
- export async function handleUndoCommand({ command, }) {
8
+ export async function handleUndoCommand({ command }) {
9
9
  const channel = command.channel;
10
10
  if (!channel) {
11
11
  await command.reply({
@@ -88,7 +88,7 @@ export async function handleUndoCommand({ command, }) {
88
88
  await command.editReply(`Failed to undo: ${error instanceof Error ? error.message : 'Unknown error'}`);
89
89
  }
90
90
  }
91
- export async function handleRedoCommand({ command, }) {
91
+ export async function handleRedoCommand({ command }) {
92
92
  const channel = command.channel;
93
93
  if (!channel) {
94
94
  await command.reply({