kimaki 0.4.44 → 0.4.45

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 (41) hide show
  1. package/dist/channel-management.js +6 -15
  2. package/dist/cli.js +54 -37
  3. package/dist/commands/permissions.js +21 -5
  4. package/dist/commands/queue.js +5 -1
  5. package/dist/commands/resume.js +8 -16
  6. package/dist/commands/session.js +18 -42
  7. package/dist/commands/user-command.js +8 -17
  8. package/dist/commands/verbosity.js +53 -0
  9. package/dist/commands/worktree-settings.js +2 -2
  10. package/dist/commands/worktree.js +132 -25
  11. package/dist/database.js +49 -0
  12. package/dist/discord-bot.js +24 -38
  13. package/dist/discord-utils.js +51 -13
  14. package/dist/discord-utils.test.js +20 -0
  15. package/dist/escape-backticks.test.js +14 -3
  16. package/dist/interaction-handler.js +4 -0
  17. package/dist/session-handler.js +541 -413
  18. package/package.json +1 -1
  19. package/src/__snapshots__/first-session-no-info.md +1344 -0
  20. package/src/__snapshots__/first-session-with-info.md +1350 -0
  21. package/src/__snapshots__/session-1.md +1344 -0
  22. package/src/__snapshots__/session-2.md +291 -0
  23. package/src/__snapshots__/session-3.md +20324 -0
  24. package/src/__snapshots__/session-with-tools.md +1344 -0
  25. package/src/channel-management.ts +6 -17
  26. package/src/cli.ts +63 -45
  27. package/src/commands/permissions.ts +31 -5
  28. package/src/commands/queue.ts +5 -1
  29. package/src/commands/resume.ts +8 -18
  30. package/src/commands/session.ts +18 -44
  31. package/src/commands/user-command.ts +8 -19
  32. package/src/commands/verbosity.ts +71 -0
  33. package/src/commands/worktree-settings.ts +2 -2
  34. package/src/commands/worktree.ts +160 -27
  35. package/src/database.ts +65 -0
  36. package/src/discord-bot.ts +26 -42
  37. package/src/discord-utils.test.ts +23 -0
  38. package/src/discord-utils.ts +52 -13
  39. package/src/escape-backticks.test.ts +14 -3
  40. package/src/interaction-handler.ts +5 -0
  41. package/src/session-handler.ts +669 -436
@@ -3,8 +3,7 @@
3
3
  // extracts channel metadata from topic tags, and ensures category structure.
4
4
  import { ChannelType } from 'discord.js';
5
5
  import path from 'node:path';
6
- import { getDatabase } from './database.js';
7
- import { extractTagsArrays } from './xml.js';
6
+ import { getDatabase, getChannelDirectory } from './database.js';
8
7
  export async function ensureKimakiCategory(guild, botName) {
9
8
  // Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
10
9
  const isKimakiBot = botName?.toLowerCase() === 'kimaki';
@@ -53,7 +52,7 @@ export async function createProjectChannels({ guild, projectDirectory, appId, bo
53
52
  name: channelName,
54
53
  type: ChannelType.GuildText,
55
54
  parent: kimakiCategory,
56
- topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
55
+ // Channel configuration is stored in SQLite, not in the topic
57
56
  });
58
57
  const voiceChannel = await guild.channels.create({
59
58
  name: channelName,
@@ -79,22 +78,14 @@ export async function getChannelsWithDescriptions(guild) {
79
78
  .forEach((channel) => {
80
79
  const textChannel = channel;
81
80
  const description = textChannel.topic || null;
82
- let kimakiDirectory;
83
- let kimakiApp;
84
- if (description) {
85
- const extracted = extractTagsArrays({
86
- xml: description,
87
- tags: ['kimaki.directory', 'kimaki.app'],
88
- });
89
- kimakiDirectory = extracted['kimaki.directory']?.[0]?.trim();
90
- kimakiApp = extracted['kimaki.app']?.[0]?.trim();
91
- }
81
+ // Get channel config from database instead of parsing XML from topic
82
+ const channelConfig = getChannelDirectory(textChannel.id);
92
83
  channels.push({
93
84
  id: textChannel.id,
94
85
  name: textChannel.name,
95
86
  description,
96
- kimakiDirectory,
97
- kimakiApp,
87
+ kimakiDirectory: channelConfig?.directory,
88
+ kimakiApp: channelConfig?.appId || undefined,
98
89
  });
99
90
  });
100
91
  return channels;
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { cac } from 'cac';
6
6
  import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
7
7
  import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js';
8
- import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
8
+ import { getChannelsWithDescriptions, createDiscordClient, getDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
9
9
  import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
10
10
  import path from 'node:path';
11
11
  import fs from 'node:fs';
@@ -15,7 +15,6 @@ import { uploadFilesToDiscord } from './discord-utils.js';
15
15
  import { spawn, spawnSync, execSync } from 'node:child_process';
16
16
  import http from 'node:http';
17
17
  import { setDataDir, getDataDir, getLockPort } from './config.js';
18
- import { extractTagsArrays } from './xml.js';
19
18
  import { sanitizeAgentName } from './commands/agent.js';
20
19
  const cliLogger = createLogger('CLI');
21
20
  const cli = cac('kimaki');
@@ -161,12 +160,12 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
161
160
  .toJSON(),
162
161
  new SlashCommandBuilder()
163
162
  .setName('new-worktree')
164
- .setDescription('Create a new git worktree and start a session thread')
163
+ .setDescription('Create a new git worktree (in thread: uses thread name if no name given)')
165
164
  .addStringOption((option) => {
166
165
  option
167
166
  .setName('name')
168
- .setDescription('Name for the worktree (will be formatted: lowercase, spaces to dashes)')
169
- .setRequired(true);
167
+ .setDescription('Name for worktree (optional in threads - uses thread name)')
168
+ .setRequired(false);
170
169
  return option;
171
170
  })
172
171
  .toJSON(),
@@ -258,6 +257,18 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
258
257
  .setName('redo')
259
258
  .setDescription('Redo previously undone changes')
260
259
  .toJSON(),
260
+ new SlashCommandBuilder()
261
+ .setName('verbosity')
262
+ .setDescription('Set output verbosity for new sessions in this channel')
263
+ .addStringOption((option) => {
264
+ option
265
+ .setName('level')
266
+ .setDescription('Verbosity level')
267
+ .setRequired(true)
268
+ .addChoices({ name: 'tools-and-text (default)', value: 'tools-and-text' }, { name: 'text-only', value: 'text-only' });
269
+ return option;
270
+ })
271
+ .toJSON(),
261
272
  ];
262
273
  // Add user-defined commands with -cmd suffix
263
274
  for (const cmd of userCommands) {
@@ -1021,20 +1032,13 @@ cli
1021
1032
  throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
1022
1033
  }
1023
1034
  const channelData = (await channelResponse.json());
1024
- if (!channelData.topic) {
1025
- s.stop('Channel has no topic');
1026
- throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`);
1027
- }
1028
- const extracted = extractTagsArrays({
1029
- xml: channelData.topic,
1030
- tags: ['kimaki.directory', 'kimaki.app'],
1031
- });
1032
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
1033
- const channelAppId = extracted['kimaki.app']?.[0]?.trim();
1034
- if (!projectDirectory) {
1035
- s.stop('No kimaki.directory tag found');
1036
- throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`);
1035
+ const channelConfig = getChannelDirectory(channelData.id);
1036
+ if (!channelConfig) {
1037
+ s.stop('Channel not configured');
1038
+ throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
1037
1039
  }
1040
+ const projectDirectory = channelConfig.directory;
1041
+ const channelAppId = channelConfig.appId || undefined;
1038
1042
  // Verify app ID matches if both are present
1039
1043
  if (channelAppId && appId && channelAppId !== appId) {
1040
1044
  s.stop('Channel belongs to different bot');
@@ -1199,23 +1203,7 @@ cli
1199
1203
  process.exit(EXIT_NO_RESTART);
1200
1204
  }
1201
1205
  const s = spinner();
1202
- s.start('Checking for existing channel...');
1203
- // Check if channel already exists
1204
- try {
1205
- const db = getDatabase();
1206
- const existingChannel = db
1207
- .prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?')
1208
- .get(absolutePath, 'text', appId);
1209
- if (existingChannel) {
1210
- s.stop('Channel already exists');
1211
- note(`Channel already exists for this directory.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`, '⚠️ Already Exists');
1212
- process.exit(0);
1213
- }
1214
- }
1215
- catch {
1216
- // Database might not exist, continue to create
1217
- }
1218
- s.message('Connecting to Discord...');
1206
+ s.start('Connecting to Discord...');
1219
1207
  const client = await createDiscordClient();
1220
1208
  await new Promise((resolve, reject) => {
1221
1209
  client.once(Events.ClientReady, () => {
@@ -1228,10 +1216,14 @@ cli
1228
1216
  // Find guild
1229
1217
  let guild;
1230
1218
  if (options.guild) {
1231
- const foundGuild = client.guilds.cache.get(options.guild);
1219
+ // Get raw guild ID from argv to avoid cac's number coercion losing precision on large IDs
1220
+ const guildArgIndex = process.argv.findIndex((arg) => arg === '-g' || arg === '--guild');
1221
+ const rawGuildArg = guildArgIndex >= 0 ? process.argv[guildArgIndex + 1] : undefined;
1222
+ const guildId = rawGuildArg || String(options.guild);
1223
+ const foundGuild = client.guilds.cache.get(guildId);
1232
1224
  if (!foundGuild) {
1233
1225
  s.stop('Guild not found');
1234
- cliLogger.error(`Guild not found: ${options.guild}`);
1226
+ cliLogger.error(`Guild not found: ${guildId}`);
1235
1227
  client.destroy();
1236
1228
  process.exit(EXIT_NO_RESTART);
1237
1229
  }
@@ -1276,6 +1268,31 @@ cli
1276
1268
  guild = firstGuild;
1277
1269
  }
1278
1270
  }
1271
+ // Check if channel already exists in this guild
1272
+ s.message('Checking for existing channel...');
1273
+ try {
1274
+ const db = getDatabase();
1275
+ const existingChannels = db
1276
+ .prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?')
1277
+ .all(absolutePath, 'text', appId);
1278
+ for (const existingChannel of existingChannels) {
1279
+ try {
1280
+ const ch = await client.channels.fetch(existingChannel.channel_id);
1281
+ if (ch && 'guild' in ch && ch.guild?.id === guild.id) {
1282
+ s.stop('Channel already exists');
1283
+ note(`Channel already exists for this directory in ${guild.name}.\n\nChannel: <#${existingChannel.channel_id}>\nDirectory: ${absolutePath}`, '⚠️ Already Exists');
1284
+ client.destroy();
1285
+ process.exit(0);
1286
+ }
1287
+ }
1288
+ catch {
1289
+ // Channel might be deleted, continue checking
1290
+ }
1291
+ }
1292
+ }
1293
+ catch {
1294
+ // Database might not exist, continue to create
1295
+ }
1279
1296
  s.message(`Creating channels in ${guild.name}...`);
1280
1297
  const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
1281
1298
  guild,
@@ -17,6 +17,7 @@ export async function showPermissionDropdown({ thread, permission, directory, })
17
17
  const contextHash = crypto.randomBytes(8).toString('hex');
18
18
  const context = {
19
19
  permission,
20
+ requestIds: [permission.id],
20
21
  directory,
21
22
  thread,
22
23
  contextHash,
@@ -80,10 +81,13 @@ export async function handlePermissionSelectMenu(interaction) {
80
81
  if (!clientV2) {
81
82
  throw new Error('OpenCode server not found for directory');
82
83
  }
83
- await clientV2.permission.reply({
84
- requestID: context.permission.id,
85
- reply: response,
86
- });
84
+ const requestIds = context.requestIds.length > 0 ? context.requestIds : [context.permission.id];
85
+ await Promise.all(requestIds.map((requestId) => {
86
+ return clientV2.permission.reply({
87
+ requestID: requestId,
88
+ reply: response,
89
+ });
90
+ }));
87
91
  pendingPermissionContexts.delete(contextHash);
88
92
  // Update message: show result and remove dropdown
89
93
  const resultText = (() => {
@@ -104,7 +108,7 @@ export async function handlePermissionSelectMenu(interaction) {
104
108
  resultText,
105
109
  components: [], // Remove the dropdown
106
110
  });
107
- logger.log(`Permission ${context.permission.id} ${response}`);
111
+ logger.log(`Permission ${context.permission.id} ${response} (${requestIds.length} request(s))`);
108
112
  }
109
113
  catch (error) {
110
114
  logger.error('Error handling permission:', error);
@@ -114,6 +118,18 @@ export async function handlePermissionSelectMenu(interaction) {
114
118
  });
115
119
  }
116
120
  }
121
+ export function addPermissionRequestToContext({ contextHash, requestId, }) {
122
+ const context = pendingPermissionContexts.get(contextHash);
123
+ if (!context) {
124
+ return false;
125
+ }
126
+ if (context.requestIds.includes(requestId)) {
127
+ return false;
128
+ }
129
+ context.requestIds = [...context.requestIds, requestId];
130
+ pendingPermissionContexts.set(contextHash, context);
131
+ return true;
132
+ }
117
133
  /**
118
134
  * Clean up a pending permission context (e.g., on auto-reject).
119
135
  */
@@ -41,7 +41,11 @@ export async function handleQueueCommand({ command }) {
41
41
  return;
42
42
  }
43
43
  // Check if there's an active request running
44
- const hasActiveRequest = abortControllers.has(row.session_id);
44
+ const existingController = abortControllers.get(row.session_id);
45
+ const hasActiveRequest = Boolean(existingController && !existingController.signal.aborted);
46
+ if (existingController && existingController.signal.aborted) {
47
+ abortControllers.delete(row.session_id);
48
+ }
45
49
  if (!hasActiveRequest) {
46
50
  // No active request, send immediately
47
51
  const textChannel = await resolveTextChannel(channel);
@@ -1,10 +1,9 @@
1
1
  // /resume command - Resume an existing OpenCode session.
2
2
  import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
3
3
  import fs from 'node:fs';
4
- import { getDatabase } from '../database.js';
4
+ import { getDatabase, getChannelDirectory } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
- import { sendThreadMessage, resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
- import { extractTagsArrays } from '../xml.js';
6
+ import { sendThreadMessage, resolveTextChannel } from '../discord-utils.js';
8
7
  import { collectLastAssistantParts } from '../message-formatting.js';
9
8
  import { createLogger } from '../logger.js';
10
9
  import * as errore from 'errore';
@@ -18,16 +17,9 @@ export async function handleResumeCommand({ command, appId }) {
18
17
  return;
19
18
  }
20
19
  const textChannel = channel;
21
- let projectDirectory;
22
- let channelAppId;
23
- if (textChannel.topic) {
24
- const extracted = extractTagsArrays({
25
- xml: textChannel.topic,
26
- tags: ['kimaki.directory', 'kimaki.app'],
27
- });
28
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
29
- channelAppId = extracted['kimaki.app']?.[0]?.trim();
30
- }
20
+ const channelConfig = getChannelDirectory(textChannel.id);
21
+ const projectDirectory = channelConfig?.directory;
22
+ const channelAppId = channelConfig?.appId || undefined;
31
23
  if (channelAppId && channelAppId !== appId) {
32
24
  await command.editReply('This channel is not configured for this bot');
33
25
  return;
@@ -102,12 +94,12 @@ export async function handleResumeAutocomplete({ interaction, appId, }) {
102
94
  if (interaction.channel) {
103
95
  const textChannel = await resolveTextChannel(interaction.channel);
104
96
  if (textChannel) {
105
- const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
106
- if (channelAppId && channelAppId !== appId) {
97
+ const channelConfig = getChannelDirectory(textChannel.id);
98
+ if (channelConfig?.appId && channelConfig.appId !== appId) {
107
99
  await interaction.respond([]);
108
100
  return;
109
101
  }
110
- projectDirectory = directory;
102
+ projectDirectory = channelConfig?.directory;
111
103
  }
112
104
  }
113
105
  if (!projectDirectory) {
@@ -2,10 +2,9 @@
2
2
  import { ChannelType } from 'discord.js';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { getDatabase } from '../database.js';
5
+ import { getDatabase, getChannelDirectory } from '../database.js';
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
7
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
8
- import { extractTagsArrays } from '../xml.js';
9
8
  import { handleOpencodeSession } from '../session-handler.js';
10
9
  import { createLogger } from '../logger.js';
11
10
  import * as errore from 'errore';
@@ -21,16 +20,9 @@ export async function handleSessionCommand({ command, appId }) {
21
20
  return;
22
21
  }
23
22
  const textChannel = channel;
24
- let projectDirectory;
25
- let channelAppId;
26
- if (textChannel.topic) {
27
- const extracted = extractTagsArrays({
28
- xml: textChannel.topic,
29
- tags: ['kimaki.directory', 'kimaki.app'],
30
- });
31
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
32
- channelAppId = extracted['kimaki.app']?.[0]?.trim();
33
- }
23
+ const channelConfig = getChannelDirectory(textChannel.id);
24
+ const projectDirectory = channelConfig?.directory;
25
+ const channelAppId = channelConfig?.appId || undefined;
34
26
  if (channelAppId && channelAppId !== appId) {
35
27
  await command.editReply('This channel is not configured for this bot');
36
28
  return;
@@ -83,22 +75,14 @@ export async function handleSessionCommand({ command, appId }) {
83
75
  async function handleAgentAutocomplete({ interaction, appId }) {
84
76
  const focusedValue = interaction.options.getFocused();
85
77
  let projectDirectory;
86
- if (interaction.channel) {
87
- const channel = interaction.channel;
88
- if (channel.type === ChannelType.GuildText) {
89
- const textChannel = channel;
90
- if (textChannel.topic) {
91
- const extracted = extractTagsArrays({
92
- xml: textChannel.topic,
93
- tags: ['kimaki.directory', 'kimaki.app'],
94
- });
95
- const channelAppId = extracted['kimaki.app']?.[0]?.trim();
96
- if (channelAppId && channelAppId !== appId) {
97
- await interaction.respond([]);
98
- return;
99
- }
100
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
78
+ if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
79
+ const channelConfig = getChannelDirectory(interaction.channel.id);
80
+ if (channelConfig) {
81
+ if (channelConfig.appId && channelConfig.appId !== appId) {
82
+ await interaction.respond([]);
83
+ return;
101
84
  }
85
+ projectDirectory = channelConfig.directory;
102
86
  }
103
87
  }
104
88
  if (!projectDirectory) {
@@ -150,22 +134,14 @@ export async function handleSessionAutocomplete({ interaction, appId, }) {
150
134
  .filter((f) => f);
151
135
  const currentQuery = (parts[parts.length - 1] || '').trim();
152
136
  let projectDirectory;
153
- if (interaction.channel) {
154
- const channel = interaction.channel;
155
- if (channel.type === ChannelType.GuildText) {
156
- const textChannel = channel;
157
- if (textChannel.topic) {
158
- const extracted = extractTagsArrays({
159
- xml: textChannel.topic,
160
- tags: ['kimaki.directory', 'kimaki.app'],
161
- });
162
- const channelAppId = extracted['kimaki.app']?.[0]?.trim();
163
- if (channelAppId && channelAppId !== appId) {
164
- await interaction.respond([]);
165
- return;
166
- }
167
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
137
+ if (interaction.channel && interaction.channel.type === ChannelType.GuildText) {
138
+ const channelConfig = getChannelDirectory(interaction.channel.id);
139
+ if (channelConfig) {
140
+ if (channelConfig.appId && channelConfig.appId !== appId) {
141
+ await interaction.respond([]);
142
+ return;
168
143
  }
144
+ projectDirectory = channelConfig.directory;
169
145
  }
170
146
  }
171
147
  if (!projectDirectory) {
@@ -1,11 +1,10 @@
1
1
  // User-defined OpenCode command handler.
2
2
  // Handles slash commands that map to user-configured commands in opencode.json.
3
3
  import { ChannelType } from 'discord.js';
4
- import { extractTagsArrays } from '../xml.js';
5
4
  import { handleOpencodeSession } from '../session-handler.js';
6
5
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
7
6
  import { createLogger } from '../logger.js';
8
- import { getDatabase } from '../database.js';
7
+ import { getDatabase, getChannelDirectory } from '../database.js';
9
8
  import fs from 'node:fs';
10
9
  const userCommandLogger = createLogger('USER_CMD');
11
10
  export const handleUserCommand = async ({ command, appId }) => {
@@ -45,26 +44,18 @@ export const handleUserCommand = async ({ command, appId }) => {
45
44
  });
46
45
  return;
47
46
  }
48
- if (textChannel?.topic) {
49
- const extracted = extractTagsArrays({
50
- xml: textChannel.topic,
51
- tags: ['kimaki.directory', 'kimaki.app'],
52
- });
53
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
54
- channelAppId = extracted['kimaki.app']?.[0]?.trim();
47
+ if (textChannel) {
48
+ const channelConfig = getChannelDirectory(textChannel.id);
49
+ projectDirectory = channelConfig?.directory;
50
+ channelAppId = channelConfig?.appId || undefined;
55
51
  }
56
52
  }
57
53
  else {
58
54
  // Running in a text channel - will create a new thread
59
55
  textChannel = channel;
60
- if (textChannel.topic) {
61
- const extracted = extractTagsArrays({
62
- xml: textChannel.topic,
63
- tags: ['kimaki.directory', 'kimaki.app'],
64
- });
65
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
66
- channelAppId = extracted['kimaki.app']?.[0]?.trim();
67
- }
56
+ const channelConfig = getChannelDirectory(textChannel.id);
57
+ projectDirectory = channelConfig?.directory;
58
+ channelAppId = channelConfig?.appId || undefined;
68
59
  }
69
60
  if (channelAppId && channelAppId !== appId) {
70
61
  await command.reply({
@@ -0,0 +1,53 @@
1
+ // /verbosity command.
2
+ // Sets the output verbosity level for sessions in a channel.
3
+ // 'tools-and-text' (default): shows all output including tool executions
4
+ // 'text-only': only shows text responses (⬥ diamond parts)
5
+ import { ChatInputCommandInteraction, ChannelType } from 'discord.js';
6
+ import { getChannelVerbosity, setChannelVerbosity } from '../database.js';
7
+ import { createLogger } from '../logger.js';
8
+ const verbosityLogger = createLogger('VERBOSITY');
9
+ /**
10
+ * Handle the /verbosity slash command.
11
+ * Sets output verbosity for the channel (applies to new sessions).
12
+ */
13
+ export async function handleVerbosityCommand({ command, appId, }) {
14
+ verbosityLogger.log('[VERBOSITY] Command called');
15
+ const channel = command.channel;
16
+ if (!channel) {
17
+ await command.reply({
18
+ content: 'Could not determine channel.',
19
+ ephemeral: true,
20
+ });
21
+ return;
22
+ }
23
+ // Get the parent channel ID (for threads, use parent; for text channels, use self)
24
+ const channelId = (() => {
25
+ if (channel.type === ChannelType.GuildText) {
26
+ return channel.id;
27
+ }
28
+ if (channel.type === ChannelType.PublicThread ||
29
+ channel.type === ChannelType.PrivateThread ||
30
+ channel.type === ChannelType.AnnouncementThread) {
31
+ return channel.parentId || channel.id;
32
+ }
33
+ return channel.id;
34
+ })();
35
+ const level = command.options.getString('level', true);
36
+ const currentLevel = getChannelVerbosity(channelId);
37
+ if (currentLevel === level) {
38
+ await command.reply({
39
+ content: `Verbosity is already set to **${level}**.`,
40
+ ephemeral: true,
41
+ });
42
+ return;
43
+ }
44
+ setChannelVerbosity(channelId, level);
45
+ verbosityLogger.log(`[VERBOSITY] Set channel ${channelId} to ${level}`);
46
+ const description = level === 'text-only'
47
+ ? 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.'
48
+ : 'All output will be shown, including tool executions and status messages.';
49
+ await command.reply({
50
+ content: `Verbosity set to **${level}**.\n\n${description}\n\nThis applies to all new sessions in this channel.`,
51
+ ephemeral: true,
52
+ });
53
+ }
@@ -31,7 +31,7 @@ export async function handleEnableWorktreesCommand({ command, appId, }) {
31
31
  }
32
32
  if (!metadata.projectDirectory) {
33
33
  await command.reply({
34
- content: 'This channel is not configured with a project directory.\nAdd a `<kimaki.directory>/path/to/project</kimaki.directory>` tag to the channel description.',
34
+ content: 'This channel is not configured with a project directory.\nUse `/add-project` to set up this channel.',
35
35
  ephemeral: true,
36
36
  });
37
37
  return;
@@ -71,7 +71,7 @@ export async function handleDisableWorktreesCommand({ command, appId, }) {
71
71
  }
72
72
  if (!metadata.projectDirectory) {
73
73
  await command.reply({
74
- content: 'This channel is not configured with a project directory.\nAdd a `<kimaki.directory>/path/to/project</kimaki.directory>` tag to the channel description.',
74
+ content: 'This channel is not configured with a project directory.\nUse `/add-project` to set up this channel.',
75
75
  ephemeral: true,
76
76
  });
77
77
  return;