kimaki 0.4.44 → 0.4.46

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 (45) hide show
  1. package/dist/channel-management.js +6 -15
  2. package/dist/cli.js +54 -37
  3. package/dist/commands/create-new-project.js +2 -0
  4. package/dist/commands/fork.js +2 -0
  5. package/dist/commands/permissions.js +21 -5
  6. package/dist/commands/queue.js +5 -1
  7. package/dist/commands/resume.js +10 -16
  8. package/dist/commands/session.js +20 -42
  9. package/dist/commands/user-command.js +10 -17
  10. package/dist/commands/verbosity.js +53 -0
  11. package/dist/commands/worktree-settings.js +2 -2
  12. package/dist/commands/worktree.js +134 -25
  13. package/dist/database.js +49 -0
  14. package/dist/discord-bot.js +26 -38
  15. package/dist/discord-utils.js +51 -13
  16. package/dist/discord-utils.test.js +20 -0
  17. package/dist/escape-backticks.test.js +14 -3
  18. package/dist/interaction-handler.js +4 -0
  19. package/dist/session-handler.js +581 -414
  20. package/package.json +1 -1
  21. package/src/__snapshots__/first-session-no-info.md +1344 -0
  22. package/src/__snapshots__/first-session-with-info.md +1350 -0
  23. package/src/__snapshots__/session-1.md +1344 -0
  24. package/src/__snapshots__/session-2.md +291 -0
  25. package/src/__snapshots__/session-3.md +20324 -0
  26. package/src/__snapshots__/session-with-tools.md +1344 -0
  27. package/src/channel-management.ts +6 -17
  28. package/src/cli.ts +63 -45
  29. package/src/commands/create-new-project.ts +3 -0
  30. package/src/commands/fork.ts +3 -0
  31. package/src/commands/permissions.ts +31 -5
  32. package/src/commands/queue.ts +5 -1
  33. package/src/commands/resume.ts +11 -18
  34. package/src/commands/session.ts +21 -44
  35. package/src/commands/user-command.ts +11 -19
  36. package/src/commands/verbosity.ts +71 -0
  37. package/src/commands/worktree-settings.ts +2 -2
  38. package/src/commands/worktree.ts +163 -27
  39. package/src/database.ts +65 -0
  40. package/src/discord-bot.ts +29 -42
  41. package/src/discord-utils.test.ts +23 -0
  42. package/src/discord-utils.ts +52 -13
  43. package/src/escape-backticks.test.ts +14 -3
  44. package/src/interaction-handler.ts +5 -0
  45. package/src/session-handler.ts +711 -436
@@ -3,10 +3,9 @@
3
3
  // Creates thread immediately, then worktree in background so user can type
4
4
  import { ChannelType } from 'discord.js';
5
5
  import fs from 'node:fs';
6
- import { createPendingWorktree, setWorktreeReady, setWorktreeError, } from '../database.js';
6
+ import { createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelDirectory, getThreadWorktree, } from '../database.js';
7
7
  import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js';
8
8
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
9
- import { extractTagsArrays } from '../xml.js';
10
9
  import { createLogger } from '../logger.js';
11
10
  import { createWorktreeWithSubmodules } from '../worktree-utils.js';
12
11
  import { WORKTREE_PREFIX } from './merge-worktree.js';
@@ -21,6 +20,7 @@ class WorktreeError extends Error {
21
20
  /**
22
21
  * Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
23
22
  * "My Feature" → "opencode/kimaki-my-feature"
23
+ * Returns empty string if no valid name can be extracted.
24
24
  */
25
25
  export function formatWorktreeName(name) {
26
26
  const formatted = name
@@ -28,31 +28,44 @@ export function formatWorktreeName(name) {
28
28
  .trim()
29
29
  .replace(/\s+/g, '-')
30
30
  .replace(/[^a-z0-9-]/g, '');
31
+ if (!formatted) {
32
+ return '';
33
+ }
31
34
  return `opencode/kimaki-${formatted}`;
32
35
  }
33
36
  /**
34
- * Get project directory from channel topic.
37
+ * Derive worktree name from thread name.
38
+ * Handles existing "⬦ worktree: opencode/kimaki-name" format or uses thread name directly.
39
+ */
40
+ function deriveWorktreeNameFromThread(threadName) {
41
+ // Handle existing "⬦ worktree: opencode/kimaki-name" format
42
+ const worktreeMatch = threadName.match(/worktree:\s*(.+)$/i);
43
+ const extractedName = worktreeMatch?.[1]?.trim();
44
+ if (extractedName) {
45
+ // If already has opencode/kimaki- prefix, return as is
46
+ if (extractedName.startsWith('opencode/kimaki-')) {
47
+ return extractedName;
48
+ }
49
+ return formatWorktreeName(extractedName);
50
+ }
51
+ // Use thread name directly
52
+ return formatWorktreeName(threadName);
53
+ }
54
+ /**
55
+ * Get project directory from database.
35
56
  */
36
57
  function getProjectDirectoryFromChannel(channel, appId) {
37
- if (!channel.topic) {
38
- return new WorktreeError('This channel has no topic configured');
58
+ const channelConfig = getChannelDirectory(channel.id);
59
+ if (!channelConfig) {
60
+ return new WorktreeError('This channel is not configured with a project directory');
39
61
  }
40
- const extracted = extractTagsArrays({
41
- xml: channel.topic,
42
- tags: ['kimaki.directory', 'kimaki.app'],
43
- });
44
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
45
- const channelAppId = extracted['kimaki.app']?.[0]?.trim();
46
- if (channelAppId && channelAppId !== appId) {
62
+ if (channelConfig.appId && channelConfig.appId !== appId) {
47
63
  return new WorktreeError('This channel is not configured for this bot');
48
64
  }
49
- if (!projectDirectory) {
50
- return new WorktreeError('This channel is not configured with a project directory');
51
- }
52
- if (!fs.existsSync(projectDirectory)) {
53
- return new WorktreeError(`Directory does not exist: ${projectDirectory}`);
65
+ if (!fs.existsSync(channelConfig.directory)) {
66
+ return new WorktreeError(`Directory does not exist: ${channelConfig.directory}`);
54
67
  }
55
- return projectDirectory;
68
+ return channelConfig.directory;
56
69
  }
57
70
  /**
58
71
  * Create worktree in background and update starter message when done.
@@ -80,15 +93,30 @@ async function createWorktreeInBackground({ thread, starterMessage, worktreeName
80
93
  }
81
94
  export async function handleNewWorktreeCommand({ command, appId, }) {
82
95
  await command.deferReply({ ephemeral: false });
83
- const rawName = command.options.getString('name', true);
84
- const worktreeName = formatWorktreeName(rawName);
85
- if (worktreeName === 'kimaki-') {
86
- await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.');
96
+ const channel = command.channel;
97
+ if (!channel) {
98
+ await command.editReply('Cannot determine channel');
87
99
  return;
88
100
  }
89
- const channel = command.channel;
90
- if (!channel || channel.type !== ChannelType.GuildText) {
91
- await command.editReply('This command can only be used in text channels');
101
+ const isThread = channel.type === ChannelType.PublicThread || channel.type === ChannelType.PrivateThread;
102
+ // Handle command in existing thread - attach worktree to this thread
103
+ if (isThread) {
104
+ await handleWorktreeInThread({ command, appId, thread: channel });
105
+ return;
106
+ }
107
+ // Handle command in text channel - create new thread with worktree (existing behavior)
108
+ if (channel.type !== ChannelType.GuildText) {
109
+ await command.editReply('This command can only be used in text channels or threads');
110
+ return;
111
+ }
112
+ const rawName = command.options.getString('name');
113
+ if (!rawName) {
114
+ await command.editReply('Name is required when creating a worktree from a text channel. Use `/new-worktree name:my-feature`');
115
+ return;
116
+ }
117
+ const worktreeName = formatWorktreeName(rawName);
118
+ if (!worktreeName) {
119
+ await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.');
92
120
  return;
93
121
  }
94
122
  const textChannel = channel;
@@ -139,6 +167,8 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
139
167
  autoArchiveDuration: 1440,
140
168
  reason: 'Worktree session',
141
169
  });
170
+ // Add user to thread so it appears in their sidebar
171
+ await thread.members.add(command.user.id);
142
172
  return { thread, starterMessage };
143
173
  },
144
174
  catch: (e) => new WorktreeError('Failed to create thread', { cause: e }),
@@ -167,3 +197,82 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
167
197
  logger.error('[NEW-WORKTREE] Background error:', e);
168
198
  });
169
199
  }
200
+ /**
201
+ * Handle /new-worktree when called inside an existing thread.
202
+ * Attaches a worktree to the current thread, using thread name if no name provided.
203
+ */
204
+ async function handleWorktreeInThread({ command, appId, thread, }) {
205
+ // Error if thread already has a worktree
206
+ if (getThreadWorktree(thread.id)) {
207
+ await command.editReply('This thread already has a worktree attached.');
208
+ return;
209
+ }
210
+ // Get worktree name from parameter or derive from thread name
211
+ const rawName = command.options.getString('name');
212
+ const worktreeName = rawName ? formatWorktreeName(rawName) : deriveWorktreeNameFromThread(thread.name);
213
+ if (!worktreeName) {
214
+ await command.editReply('Invalid worktree name. Please provide a name or rename the thread.');
215
+ return;
216
+ }
217
+ // Get parent channel for project directory
218
+ const parent = thread.parent;
219
+ if (!parent || parent.type !== ChannelType.GuildText) {
220
+ await command.editReply('Cannot determine parent channel');
221
+ return;
222
+ }
223
+ const projectDirectory = getProjectDirectoryFromChannel(parent, appId);
224
+ if (errore.isError(projectDirectory)) {
225
+ await command.editReply(projectDirectory.message);
226
+ return;
227
+ }
228
+ // Initialize opencode
229
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
230
+ if (errore.isError(getClient)) {
231
+ await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`);
232
+ return;
233
+ }
234
+ const clientV2 = getOpencodeClientV2(projectDirectory);
235
+ if (!clientV2) {
236
+ await command.editReply('Failed to get OpenCode client');
237
+ return;
238
+ }
239
+ // Check if worktree with this name already exists
240
+ const listResult = await errore.tryAsync({
241
+ try: async () => {
242
+ const response = await clientV2.worktree.list({ directory: projectDirectory });
243
+ return response.data || [];
244
+ },
245
+ catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
246
+ });
247
+ if (errore.isError(listResult)) {
248
+ await command.editReply(listResult.message);
249
+ return;
250
+ }
251
+ const existingWorktreePath = listResult.find((dir) => dir.endsWith(`/${worktreeName}`));
252
+ if (existingWorktreePath) {
253
+ await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktreePath}\``);
254
+ return;
255
+ }
256
+ // Store pending worktree in database for this existing thread
257
+ createPendingWorktree({
258
+ threadId: thread.id,
259
+ worktreeName,
260
+ projectDirectory,
261
+ });
262
+ // Send status message in thread
263
+ const statusMessage = await thread.send({
264
+ content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
265
+ flags: SILENT_MESSAGE_FLAGS,
266
+ });
267
+ await command.editReply(`Creating worktree \`${worktreeName}\` for this thread...`);
268
+ // Create worktree in background
269
+ createWorktreeInBackground({
270
+ thread,
271
+ starterMessage: statusMessage,
272
+ worktreeName,
273
+ projectDirectory,
274
+ clientV2,
275
+ }).catch((e) => {
276
+ logger.error('[NEW-WORKTREE] Background error:', e);
277
+ });
278
+ }
package/dist/database.js CHANGED
@@ -91,6 +91,7 @@ export function getDatabase() {
91
91
  `);
92
92
  runModelMigrations(db);
93
93
  runWorktreeSettingsMigrations(db);
94
+ runVerbosityMigrations(db);
94
95
  }
95
96
  return db;
96
97
  }
@@ -267,6 +268,37 @@ export function runWorktreeSettingsMigrations(database) {
267
268
  `);
268
269
  dbLogger.log('Channel worktree settings migrations complete');
269
270
  }
271
+ export function runVerbosityMigrations(database) {
272
+ const targetDb = database || getDatabase();
273
+ targetDb.exec(`
274
+ CREATE TABLE IF NOT EXISTS channel_verbosity (
275
+ channel_id TEXT PRIMARY KEY,
276
+ verbosity TEXT NOT NULL DEFAULT 'tools-and-text',
277
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
278
+ )
279
+ `);
280
+ dbLogger.log('Channel verbosity settings migrations complete');
281
+ }
282
+ /**
283
+ * Get the verbosity setting for a channel.
284
+ * @returns 'tools-and-text' (default) or 'text-only'
285
+ */
286
+ export function getChannelVerbosity(channelId) {
287
+ const db = getDatabase();
288
+ const row = db
289
+ .prepare('SELECT verbosity FROM channel_verbosity WHERE channel_id = ?')
290
+ .get(channelId);
291
+ return row?.verbosity || 'tools-and-text';
292
+ }
293
+ /**
294
+ * Set the verbosity setting for a channel.
295
+ */
296
+ export function setChannelVerbosity(channelId, verbosity) {
297
+ const db = getDatabase();
298
+ db.prepare(`INSERT INTO channel_verbosity (channel_id, verbosity, updated_at)
299
+ VALUES (?, ?, CURRENT_TIMESTAMP)
300
+ ON CONFLICT(channel_id) DO UPDATE SET verbosity = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, verbosity, verbosity);
301
+ }
270
302
  /**
271
303
  * Check if automatic worktree creation is enabled for a channel.
272
304
  */
@@ -286,6 +318,23 @@ export function setChannelWorktreesEnabled(channelId, enabled) {
286
318
  VALUES (?, ?, CURRENT_TIMESTAMP)
287
319
  ON CONFLICT(channel_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, enabled ? 1 : 0, enabled ? 1 : 0);
288
320
  }
321
+ /**
322
+ * Get the directory and app_id for a channel from the database.
323
+ * This is the single source of truth for channel-project mappings.
324
+ */
325
+ export function getChannelDirectory(channelId) {
326
+ const db = getDatabase();
327
+ const row = db
328
+ .prepare('SELECT directory, app_id FROM channel_directories WHERE channel_id = ?')
329
+ .get(channelId);
330
+ if (!row) {
331
+ return undefined;
332
+ }
333
+ return {
334
+ directory: row.directory,
335
+ appId: row.app_id,
336
+ };
337
+ }
289
338
  export function closeDatabase() {
290
339
  if (db) {
291
340
  db.close();
@@ -1,7 +1,7 @@
1
1
  // Core Discord bot module that handles message events and bot lifecycle.
2
2
  // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
3
  // and orchestrates the main event loop for the Kimaki bot.
4
- import { getDatabase, closeDatabase, getThreadWorktree, createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelWorktreesEnabled, } from './database.js';
4
+ import { getDatabase, closeDatabase, getThreadWorktree, createPendingWorktree, setWorktreeReady, setWorktreeError, getChannelWorktreesEnabled, getChannelDirectory, } from './database.js';
5
5
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
6
6
  import { formatWorktreeName } from './commands/worktree.js';
7
7
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
@@ -14,7 +14,7 @@ import { voiceConnections, cleanupVoiceConnection, processVoiceAttachment, regis
14
14
  import { getCompactSessionContext, getLastSessionId } from './markdown.js';
15
15
  import { handleOpencodeSession } from './session-handler.js';
16
16
  import { registerInteractionHandler } from './interaction-handler.js';
17
- export { getDatabase, closeDatabase } from './database.js';
17
+ export { getDatabase, closeDatabase, getChannelDirectory } from './database.js';
18
18
  export { initializeOpencodeForDirectory } from './opencode.js';
19
19
  export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js';
20
20
  export { getOpencodeSystemMessage } from './system-message.js';
@@ -22,7 +22,6 @@ export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels,
22
22
  import { ChannelType, Client, Events, GatewayIntentBits, Partials, PermissionsBitField, ThreadAutoArchiveDuration, } from 'discord.js';
23
23
  import fs from 'node:fs';
24
24
  import * as errore from 'errore';
25
- import { extractTagsArrays } from './xml.js';
26
25
  import { createLogger } from './logger.js';
27
26
  import { setGlobalDispatcher, Agent } from 'undici';
28
27
  // Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
@@ -130,13 +129,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
130
129
  const parent = thread.parent;
131
130
  let projectDirectory;
132
131
  let channelAppId;
133
- if (parent?.topic) {
134
- const extracted = extractTagsArrays({
135
- xml: parent.topic,
136
- tags: ['kimaki.directory', 'kimaki.app'],
137
- });
138
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
139
- channelAppId = extracted['kimaki.app']?.[0]?.trim();
132
+ if (parent) {
133
+ const channelConfig = getChannelDirectory(parent.id);
134
+ if (channelConfig) {
135
+ projectDirectory = channelConfig.directory;
136
+ channelAppId = channelConfig.appId || undefined;
137
+ }
140
138
  }
141
139
  // Check if this thread is a worktree thread
142
140
  const worktreeInfo = getThreadWorktree(thread.id);
@@ -155,9 +153,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
155
153
  });
156
154
  return;
157
155
  }
158
- if (worktreeInfo.worktree_directory) {
159
- projectDirectory = worktreeInfo.worktree_directory;
160
- discordLogger.log(`Using worktree directory: ${projectDirectory}`);
156
+ // Use original project directory for OpenCode server (session lives there)
157
+ // The worktree directory is passed via query.directory in prompt/command calls
158
+ if (worktreeInfo.project_directory) {
159
+ projectDirectory = worktreeInfo.project_directory;
160
+ discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.worktree_directory})`);
161
161
  }
162
162
  }
163
163
  if (channelAppId && channelAppId !== currentAppId) {
@@ -271,20 +271,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
271
271
  if (channel.type === ChannelType.GuildText) {
272
272
  const textChannel = channel;
273
273
  voiceLogger.log(`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`);
274
- if (!textChannel.topic) {
275
- voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no description`);
276
- return;
277
- }
278
- const extracted = extractTagsArrays({
279
- xml: textChannel.topic,
280
- tags: ['kimaki.directory', 'kimaki.app'],
281
- });
282
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
283
- const channelAppId = extracted['kimaki.app']?.[0]?.trim();
284
- if (!projectDirectory) {
285
- voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`);
274
+ const channelConfig = getChannelDirectory(textChannel.id);
275
+ if (!channelConfig) {
276
+ voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no project directory configured`);
286
277
  return;
287
278
  }
279
+ const projectDirectory = channelConfig.directory;
280
+ const channelAppId = channelConfig.appId || undefined;
288
281
  if (channelAppId && channelAppId !== currentAppId) {
289
282
  voiceLogger.log(`[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`);
290
283
  return;
@@ -316,6 +309,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
316
309
  autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
317
310
  reason: 'Start Claude session',
318
311
  });
312
+ // Add user to thread so it appears in their sidebar
313
+ await thread.members.add(message.author.id);
319
314
  discordLogger.log(`Created thread "${thread.name}" (${thread.id})`);
320
315
  // Create worktree if worktrees are enabled (CLI flag OR channel setting)
321
316
  let sessionDirectory = projectDirectory;
@@ -437,21 +432,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
437
432
  discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
438
433
  return;
439
434
  }
440
- // Extract directory from parent channel topic
441
- if (!parent.topic) {
442
- discordLogger.log(`[BOT_SESSION] Parent channel has no topic`);
443
- return;
444
- }
445
- const extracted = extractTagsArrays({
446
- xml: parent.topic,
447
- tags: ['kimaki.directory', 'kimaki.app'],
448
- });
449
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
450
- const channelAppId = extracted['kimaki.app']?.[0]?.trim();
451
- if (!projectDirectory) {
452
- discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`);
435
+ // Get directory from database
436
+ const channelConfig = getChannelDirectory(parent.id);
437
+ if (!channelConfig) {
438
+ discordLogger.log(`[BOT_SESSION] No project directory configured for parent channel`);
453
439
  return;
454
440
  }
441
+ const projectDirectory = channelConfig.directory;
442
+ const channelAppId = channelConfig.appId || undefined;
455
443
  if (channelAppId && channelAppId !== currentAppId) {
456
444
  discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`);
457
445
  return;
@@ -3,8 +3,8 @@
3
3
  // thread message sending, and channel metadata extraction from topic tags.
4
4
  import { ChannelType } from 'discord.js';
5
5
  import { Lexer } from 'marked';
6
- import { extractTagsArrays } from './xml.js';
7
6
  import { formatMarkdownTables } from './format-tables.js';
7
+ import { getChannelDirectory } from './database.js';
8
8
  import { limitHeadingDepth } from './limit-heading-depth.js';
9
9
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
10
10
  import { createLogger } from './logger.js';
@@ -106,8 +106,14 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
106
106
  }
107
107
  return pieces;
108
108
  };
109
+ const closingFence = '```\n';
109
110
  for (const line of lines) {
110
- const wouldExceed = currentChunk.length + line.text.length > maxLength;
111
+ const openingFenceSize = currentChunk.length === 0 && (line.inCodeBlock || line.isOpeningFence)
112
+ ? ('```' + line.lang + '\n').length
113
+ : 0;
114
+ const lineLength = line.isOpeningFence ? 0 : line.text.length;
115
+ const activeFenceOverhead = currentLang !== null || openingFenceSize > 0 ? closingFence.length : 0;
116
+ const wouldExceed = currentChunk.length + openingFenceSize + lineLength + activeFenceOverhead > maxLength;
111
117
  if (wouldExceed) {
112
118
  // handle case where single line is longer than maxLength
113
119
  if (line.text.length > maxLength) {
@@ -164,9 +170,37 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
164
170
  }
165
171
  else {
166
172
  // currentChunk is empty but line still exceeds - shouldn't happen after above check
167
- currentChunk = line.text;
168
- if (line.inCodeBlock || line.isOpeningFence) {
169
- currentLang = line.lang;
173
+ const openingFence = line.inCodeBlock || line.isOpeningFence;
174
+ const openingFenceSize = openingFence ? ('```' + line.lang + '\n').length : 0;
175
+ if (line.text.length + openingFenceSize + activeFenceOverhead > maxLength) {
176
+ const fencedOverhead = openingFence
177
+ ? ('```' + line.lang + '\n').length + closingFence.length
178
+ : 0;
179
+ const availablePerChunk = Math.max(10, maxLength - fencedOverhead - 50);
180
+ const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
181
+ for (const piece of pieces) {
182
+ if (openingFence) {
183
+ chunks.push('```' + line.lang + '\n' + piece + closingFence);
184
+ }
185
+ else {
186
+ chunks.push(piece);
187
+ }
188
+ }
189
+ currentChunk = '';
190
+ currentLang = null;
191
+ }
192
+ else {
193
+ if (openingFence) {
194
+ currentChunk = '```' + line.lang + '\n';
195
+ if (!line.isOpeningFence) {
196
+ currentChunk += line.text;
197
+ }
198
+ currentLang = line.lang;
199
+ }
200
+ else {
201
+ currentChunk = line.text;
202
+ currentLang = null;
203
+ }
170
204
  }
171
205
  }
172
206
  }
@@ -181,6 +215,9 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
181
215
  }
182
216
  }
183
217
  if (currentChunk) {
218
+ if (currentLang !== null) {
219
+ currentChunk += closingFence;
220
+ }
184
221
  chunks.push(currentChunk);
185
222
  }
186
223
  return chunks;
@@ -236,16 +273,17 @@ export function escapeDiscordFormatting(text) {
236
273
  return text.replace(/```/g, '\\`\\`\\`').replace(/````/g, '\\`\\`\\`\\`');
237
274
  }
238
275
  export function getKimakiMetadata(textChannel) {
239
- if (!textChannel?.topic) {
276
+ if (!textChannel) {
240
277
  return {};
241
278
  }
242
- const extracted = extractTagsArrays({
243
- xml: textChannel.topic,
244
- tags: ['kimaki.directory', 'kimaki.app'],
245
- });
246
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
247
- const channelAppId = extracted['kimaki.app']?.[0]?.trim();
248
- return { projectDirectory, channelAppId };
279
+ const channelConfig = getChannelDirectory(textChannel.id);
280
+ if (!channelConfig) {
281
+ return {};
282
+ }
283
+ return {
284
+ projectDirectory: channelConfig.directory,
285
+ channelAppId: channelConfig.appId || undefined,
286
+ };
249
287
  }
250
288
  /**
251
289
  * Upload files to a Discord thread/channel in a single message.
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { splitMarkdownForDiscord } from './discord-utils.js';
3
+ describe('splitMarkdownForDiscord', () => {
4
+ test('never returns chunks over the max length with code fences', () => {
5
+ const maxLength = 2000;
6
+ const header = '## Summary of Current Architecture\n\n';
7
+ const codeFenceStart = '```\n';
8
+ const codeFenceEnd = '\n```\n';
9
+ const codeLine = 'x'.repeat(180);
10
+ const codeBlock = Array.from({ length: 20 })
11
+ .map(() => codeLine)
12
+ .join('\n');
13
+ const markdown = `${header}${codeFenceStart}${codeBlock}${codeFenceEnd}`;
14
+ const chunks = splitMarkdownForDiscord({ content: markdown, maxLength });
15
+ expect(chunks.length).toBeGreaterThan(1);
16
+ for (const chunk of chunks) {
17
+ expect(chunk.length).toBeLessThanOrEqual(maxLength);
18
+ }
19
+ });
20
+ });
@@ -171,11 +171,17 @@ test('splitMarkdownForDiscord adds closing and opening fences when splitting cod
171
171
  [
172
172
  "\`\`\`js
173
173
  line1
174
+ \`\`\`
175
+ ",
176
+ "\`\`\`js
174
177
  line2
175
178
  \`\`\`
176
179
  ",
177
180
  "\`\`\`js
178
181
  line3
182
+ \`\`\`
183
+ ",
184
+ "\`\`\`js
179
185
  line4
180
186
  \`\`\`
181
187
  ",
@@ -209,10 +215,12 @@ test('splitMarkdownForDiscord handles mixed content with code blocks', () => {
209
215
  [
210
216
  "Text before
211
217
  \`\`\`js
212
- code
213
218
  \`\`\`
214
219
  ",
215
- "Text after",
220
+ "\`\`\`js
221
+ code
222
+ \`\`\`
223
+ Text after",
216
224
  ]
217
225
  `);
218
226
  });
@@ -224,6 +232,9 @@ test('splitMarkdownForDiscord handles code block without language', () => {
224
232
  expect(result).toMatchInlineSnapshot(`
225
233
  [
226
234
  "\`\`\`
235
+ \`\`\`
236
+ ",
237
+ "\`\`\`
227
238
  line1
228
239
  \`\`\`
229
240
  ",
@@ -402,10 +413,10 @@ And here is some text after the code block.`;
402
413
 
403
414
  export function formatCurrency(amount: number): string {
404
415
  return new Intl.NumberFormat('en-US', {
405
- style: 'currency',
406
416
  \`\`\`
407
417
  ",
408
418
  "\`\`\`typescript
419
+ style: 'currency',
409
420
  currency: 'USD',
410
421
  }).format(amount)
411
422
  }
@@ -20,6 +20,7 @@ import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
20
20
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js';
21
21
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
22
22
  import { handleUserCommand } from './commands/user-command.js';
23
+ import { handleVerbosityCommand } from './commands/verbosity.js';
23
24
  import { createLogger } from './logger.js';
24
25
  const interactionLogger = createLogger('INTERACTION');
25
26
  export function registerInteractionHandler({ discordClient, appId, }) {
@@ -108,6 +109,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
108
109
  case 'redo':
109
110
  await handleRedoCommand({ command: interaction, appId });
110
111
  return;
112
+ case 'verbosity':
113
+ await handleVerbosityCommand({ command: interaction, appId });
114
+ return;
111
115
  }
112
116
  // Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
113
117
  if (interaction.commandName.endsWith('-agent') && interaction.commandName !== 'agent') {