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
@@ -9,10 +9,11 @@ import {
9
9
  createPendingWorktree,
10
10
  setWorktreeReady,
11
11
  setWorktreeError,
12
+ getChannelDirectory,
13
+ getThreadWorktree,
12
14
  } from '../database.js'
13
15
  import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js'
14
16
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
15
- import { extractTagsArrays } from '../xml.js'
16
17
  import { createLogger } from '../logger.js'
17
18
  import { createWorktreeWithSubmodules } from '../worktree-utils.js'
18
19
  import { WORKTREE_PREFIX } from './merge-worktree.js'
@@ -30,6 +31,7 @@ class WorktreeError extends Error {
30
31
  /**
31
32
  * Format worktree name: lowercase, spaces to dashes, remove special chars, add opencode/kimaki- prefix.
32
33
  * "My Feature" → "opencode/kimaki-my-feature"
34
+ * Returns empty string if no valid name can be extracted.
33
35
  */
34
36
  export function formatWorktreeName(name: string): string {
35
37
  const formatted = name
@@ -38,41 +40,53 @@ export function formatWorktreeName(name: string): string {
38
40
  .replace(/\s+/g, '-')
39
41
  .replace(/[^a-z0-9-]/g, '')
40
42
 
43
+ if (!formatted) {
44
+ return ''
45
+ }
41
46
  return `opencode/kimaki-${formatted}`
42
47
  }
43
48
 
44
49
  /**
45
- * Get project directory from channel topic.
50
+ * Derive worktree name from thread name.
51
+ * Handles existing "⬦ worktree: opencode/kimaki-name" format or uses thread name directly.
52
+ */
53
+ function deriveWorktreeNameFromThread(threadName: string): string {
54
+ // Handle existing "⬦ worktree: opencode/kimaki-name" format
55
+ const worktreeMatch = threadName.match(/worktree:\s*(.+)$/i)
56
+ const extractedName = worktreeMatch?.[1]?.trim()
57
+ if (extractedName) {
58
+ // If already has opencode/kimaki- prefix, return as is
59
+ if (extractedName.startsWith('opencode/kimaki-')) {
60
+ return extractedName
61
+ }
62
+ return formatWorktreeName(extractedName)
63
+ }
64
+ // Use thread name directly
65
+ return formatWorktreeName(threadName)
66
+ }
67
+
68
+ /**
69
+ * Get project directory from database.
46
70
  */
47
71
  function getProjectDirectoryFromChannel(
48
72
  channel: TextChannel,
49
73
  appId: string,
50
74
  ): string | WorktreeError {
51
- if (!channel.topic) {
52
- return new WorktreeError('This channel has no topic configured')
53
- }
54
-
55
- const extracted = extractTagsArrays({
56
- xml: channel.topic,
57
- tags: ['kimaki.directory', 'kimaki.app'],
58
- })
75
+ const channelConfig = getChannelDirectory(channel.id)
59
76
 
60
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
61
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
62
-
63
- if (channelAppId && channelAppId !== appId) {
64
- return new WorktreeError('This channel is not configured for this bot')
77
+ if (!channelConfig) {
78
+ return new WorktreeError('This channel is not configured with a project directory')
65
79
  }
66
80
 
67
- if (!projectDirectory) {
68
- return new WorktreeError('This channel is not configured with a project directory')
81
+ if (channelConfig.appId && channelConfig.appId !== appId) {
82
+ return new WorktreeError('This channel is not configured for this bot')
69
83
  }
70
84
 
71
- if (!fs.existsSync(projectDirectory)) {
72
- return new WorktreeError(`Directory does not exist: ${projectDirectory}`)
85
+ if (!fs.existsSync(channelConfig.directory)) {
86
+ return new WorktreeError(`Directory does not exist: ${channelConfig.directory}`)
73
87
  }
74
88
 
75
- return projectDirectory
89
+ return channelConfig.directory
76
90
  }
77
91
 
78
92
  /**
@@ -122,18 +136,38 @@ export async function handleNewWorktreeCommand({
122
136
  }: CommandContext): Promise<void> {
123
137
  await command.deferReply({ ephemeral: false })
124
138
 
125
- const rawName = command.options.getString('name', true)
126
- const worktreeName = formatWorktreeName(rawName)
139
+ const channel = command.channel
140
+ if (!channel) {
141
+ await command.editReply('Cannot determine channel')
142
+ return
143
+ }
127
144
 
128
- if (worktreeName === 'kimaki-') {
129
- await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.')
145
+ const isThread =
146
+ channel.type === ChannelType.PublicThread || channel.type === ChannelType.PrivateThread
147
+
148
+ // Handle command in existing thread - attach worktree to this thread
149
+ if (isThread) {
150
+ await handleWorktreeInThread({ command, appId, thread: channel as ThreadChannel })
130
151
  return
131
152
  }
132
153
 
133
- const channel = command.channel
154
+ // Handle command in text channel - create new thread with worktree (existing behavior)
155
+ if (channel.type !== ChannelType.GuildText) {
156
+ await command.editReply('This command can only be used in text channels or threads')
157
+ return
158
+ }
159
+
160
+ const rawName = command.options.getString('name')
161
+ if (!rawName) {
162
+ await command.editReply(
163
+ 'Name is required when creating a worktree from a text channel. Use `/new-worktree name:my-feature`',
164
+ )
165
+ return
166
+ }
134
167
 
135
- if (!channel || channel.type !== ChannelType.GuildText) {
136
- await command.editReply('This command can only be used in text channels')
168
+ const worktreeName = formatWorktreeName(rawName)
169
+ if (!worktreeName) {
170
+ await command.editReply('Invalid worktree name. Please use letters, numbers, and spaces.')
137
171
  return
138
172
  }
139
173
 
@@ -227,3 +261,102 @@ export async function handleNewWorktreeCommand({
227
261
  logger.error('[NEW-WORKTREE] Background error:', e)
228
262
  })
229
263
  }
264
+
265
+ /**
266
+ * Handle /new-worktree when called inside an existing thread.
267
+ * Attaches a worktree to the current thread, using thread name if no name provided.
268
+ */
269
+ async function handleWorktreeInThread({
270
+ command,
271
+ appId,
272
+ thread,
273
+ }: CommandContext & { thread: ThreadChannel }): Promise<void> {
274
+ // Error if thread already has a worktree
275
+ if (getThreadWorktree(thread.id)) {
276
+ await command.editReply('This thread already has a worktree attached.')
277
+ return
278
+ }
279
+
280
+ // Get worktree name from parameter or derive from thread name
281
+ const rawName = command.options.getString('name')
282
+ const worktreeName = rawName ? formatWorktreeName(rawName) : deriveWorktreeNameFromThread(thread.name)
283
+
284
+ if (!worktreeName) {
285
+ await command.editReply('Invalid worktree name. Please provide a name or rename the thread.')
286
+ return
287
+ }
288
+
289
+ // Get parent channel for project directory
290
+ const parent = thread.parent
291
+ if (!parent || parent.type !== ChannelType.GuildText) {
292
+ await command.editReply('Cannot determine parent channel')
293
+ return
294
+ }
295
+
296
+ const projectDirectory = getProjectDirectoryFromChannel(parent as TextChannel, appId)
297
+ if (errore.isError(projectDirectory)) {
298
+ await command.editReply(projectDirectory.message)
299
+ return
300
+ }
301
+
302
+ // Initialize opencode
303
+ const getClient = await initializeOpencodeForDirectory(projectDirectory)
304
+ if (errore.isError(getClient)) {
305
+ await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`)
306
+ return
307
+ }
308
+
309
+ const clientV2 = getOpencodeClientV2(projectDirectory)
310
+ if (!clientV2) {
311
+ await command.editReply('Failed to get OpenCode client')
312
+ return
313
+ }
314
+
315
+ // Check if worktree with this name already exists
316
+ const listResult = await errore.tryAsync({
317
+ try: async () => {
318
+ const response = await clientV2.worktree.list({ directory: projectDirectory })
319
+ return response.data || []
320
+ },
321
+ catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
322
+ })
323
+
324
+ if (errore.isError(listResult)) {
325
+ await command.editReply(listResult.message)
326
+ return
327
+ }
328
+
329
+ const existingWorktreePath = listResult.find((dir) => dir.endsWith(`/${worktreeName}`))
330
+ if (existingWorktreePath) {
331
+ await command.editReply(
332
+ `Worktree \`${worktreeName}\` already exists at \`${existingWorktreePath}\``,
333
+ )
334
+ return
335
+ }
336
+
337
+ // Store pending worktree in database for this existing thread
338
+ createPendingWorktree({
339
+ threadId: thread.id,
340
+ worktreeName,
341
+ projectDirectory,
342
+ })
343
+
344
+ // Send status message in thread
345
+ const statusMessage = await thread.send({
346
+ content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
347
+ flags: SILENT_MESSAGE_FLAGS,
348
+ })
349
+
350
+ await command.editReply(`Creating worktree \`${worktreeName}\` for this thread...`)
351
+
352
+ // Create worktree in background
353
+ createWorktreeInBackground({
354
+ thread,
355
+ starterMessage: statusMessage,
356
+ worktreeName,
357
+ projectDirectory,
358
+ clientV2,
359
+ }).catch((e) => {
360
+ logger.error('[NEW-WORKTREE] Background error:', e)
361
+ })
362
+ }
package/src/database.ts CHANGED
@@ -106,6 +106,7 @@ export function getDatabase(): Database.Database {
106
106
 
107
107
  runModelMigrations(db)
108
108
  runWorktreeSettingsMigrations(db)
109
+ runVerbosityMigrations(db)
109
110
  }
110
111
 
111
112
  return db
@@ -358,6 +359,47 @@ export function runWorktreeSettingsMigrations(database?: Database.Database): voi
358
359
  dbLogger.log('Channel worktree settings migrations complete')
359
360
  }
360
361
 
362
+ // Verbosity levels for controlling output detail
363
+ export type VerbosityLevel = 'tools-and-text' | 'text-only'
364
+
365
+ export function runVerbosityMigrations(database?: Database.Database): void {
366
+ const targetDb = database || getDatabase()
367
+
368
+ targetDb.exec(`
369
+ CREATE TABLE IF NOT EXISTS channel_verbosity (
370
+ channel_id TEXT PRIMARY KEY,
371
+ verbosity TEXT NOT NULL DEFAULT 'tools-and-text',
372
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
373
+ )
374
+ `)
375
+
376
+ dbLogger.log('Channel verbosity settings migrations complete')
377
+ }
378
+
379
+ /**
380
+ * Get the verbosity setting for a channel.
381
+ * @returns 'tools-and-text' (default) or 'text-only'
382
+ */
383
+ export function getChannelVerbosity(channelId: string): VerbosityLevel {
384
+ const db = getDatabase()
385
+ const row = db
386
+ .prepare('SELECT verbosity FROM channel_verbosity WHERE channel_id = ?')
387
+ .get(channelId) as { verbosity: string } | undefined
388
+ return (row?.verbosity as VerbosityLevel) || 'tools-and-text'
389
+ }
390
+
391
+ /**
392
+ * Set the verbosity setting for a channel.
393
+ */
394
+ export function setChannelVerbosity(channelId: string, verbosity: VerbosityLevel): void {
395
+ const db = getDatabase()
396
+ db.prepare(
397
+ `INSERT INTO channel_verbosity (channel_id, verbosity, updated_at)
398
+ VALUES (?, ?, CURRENT_TIMESTAMP)
399
+ ON CONFLICT(channel_id) DO UPDATE SET verbosity = ?, updated_at = CURRENT_TIMESTAMP`,
400
+ ).run(channelId, verbosity, verbosity)
401
+ }
402
+
361
403
  /**
362
404
  * Check if automatic worktree creation is enabled for a channel.
363
405
  */
@@ -381,6 +423,29 @@ export function setChannelWorktreesEnabled(channelId: string, enabled: boolean):
381
423
  ).run(channelId, enabled ? 1 : 0, enabled ? 1 : 0)
382
424
  }
383
425
 
426
+ /**
427
+ * Get the directory and app_id for a channel from the database.
428
+ * This is the single source of truth for channel-project mappings.
429
+ */
430
+ export function getChannelDirectory(channelId: string): {
431
+ directory: string
432
+ appId: string | null
433
+ } | undefined {
434
+ const db = getDatabase()
435
+ const row = db
436
+ .prepare('SELECT directory, app_id FROM channel_directories WHERE channel_id = ?')
437
+ .get(channelId) as { directory: string; app_id: string | null } | undefined
438
+
439
+ if (!row) {
440
+ return undefined
441
+ }
442
+
443
+ return {
444
+ directory: row.directory,
445
+ appId: row.app_id,
446
+ }
447
+ }
448
+
384
449
  export function closeDatabase(): void {
385
450
  if (db) {
386
451
  db.close()
@@ -10,6 +10,7 @@ import {
10
10
  setWorktreeReady,
11
11
  setWorktreeError,
12
12
  getChannelWorktreesEnabled,
13
+ getChannelDirectory,
13
14
  } from './database.js'
14
15
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
15
16
  import { formatWorktreeName } from './commands/worktree.js'
@@ -39,7 +40,7 @@ import { getCompactSessionContext, getLastSessionId } from './markdown.js'
39
40
  import { handleOpencodeSession } from './session-handler.js'
40
41
  import { registerInteractionHandler } from './interaction-handler.js'
41
42
 
42
- export { getDatabase, closeDatabase } from './database.js'
43
+ export { getDatabase, closeDatabase, getChannelDirectory } from './database.js'
43
44
  export { initializeOpencodeForDirectory } from './opencode.js'
44
45
  export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js'
45
46
  export { getOpencodeSystemMessage } from './system-message.js'
@@ -65,7 +66,6 @@ import {
65
66
  } from 'discord.js'
66
67
  import fs from 'node:fs'
67
68
  import * as errore from 'errore'
68
- import { extractTagsArrays } from './xml.js'
69
69
  import { createLogger } from './logger.js'
70
70
  import { setGlobalDispatcher, Agent } from 'undici'
71
71
 
@@ -211,14 +211,12 @@ export async function startDiscordBot({
211
211
  let projectDirectory: string | undefined
212
212
  let channelAppId: string | undefined
213
213
 
214
- if (parent?.topic) {
215
- const extracted = extractTagsArrays({
216
- xml: parent.topic,
217
- tags: ['kimaki.directory', 'kimaki.app'],
218
- })
219
-
220
- projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
221
- channelAppId = extracted['kimaki.app']?.[0]?.trim()
214
+ if (parent) {
215
+ const channelConfig = getChannelDirectory(parent.id)
216
+ if (channelConfig) {
217
+ projectDirectory = channelConfig.directory
218
+ channelAppId = channelConfig.appId || undefined
219
+ }
222
220
  }
223
221
 
224
222
  // Check if this thread is a worktree thread
@@ -238,9 +236,11 @@ export async function startDiscordBot({
238
236
  })
239
237
  return
240
238
  }
241
- if (worktreeInfo.worktree_directory) {
242
- projectDirectory = worktreeInfo.worktree_directory
243
- discordLogger.log(`Using worktree directory: ${projectDirectory}`)
239
+ // Use original project directory for OpenCode server (session lives there)
240
+ // The worktree directory is passed via query.directory in prompt/command calls
241
+ if (worktreeInfo.project_directory) {
242
+ projectDirectory = worktreeInfo.project_directory
243
+ discordLogger.log(`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.worktree_directory})`)
244
244
  }
245
245
  }
246
246
 
@@ -374,24 +374,16 @@ export async function startDiscordBot({
374
374
  `[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`,
375
375
  )
376
376
 
377
- if (!textChannel.topic) {
378
- voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no description`)
379
- return
380
- }
381
-
382
- const extracted = extractTagsArrays({
383
- xml: textChannel.topic,
384
- tags: ['kimaki.directory', 'kimaki.app'],
385
- })
386
-
387
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
388
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
377
+ const channelConfig = getChannelDirectory(textChannel.id)
389
378
 
390
- if (!projectDirectory) {
391
- voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no kimaki.directory tag`)
379
+ if (!channelConfig) {
380
+ voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no project directory configured`)
392
381
  return
393
382
  }
394
383
 
384
+ const projectDirectory = channelConfig.directory
385
+ const channelAppId = channelConfig.appId || undefined
386
+
395
387
  if (channelAppId && channelAppId !== currentAppId) {
396
388
  voiceLogger.log(
397
389
  `[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
@@ -566,25 +558,17 @@ export async function startDiscordBot({
566
558
  return
567
559
  }
568
560
 
569
- // Extract directory from parent channel topic
570
- if (!parent.topic) {
571
- discordLogger.log(`[BOT_SESSION] Parent channel has no topic`)
572
- return
573
- }
561
+ // Get directory from database
562
+ const channelConfig = getChannelDirectory(parent.id)
574
563
 
575
- const extracted = extractTagsArrays({
576
- xml: parent.topic,
577
- tags: ['kimaki.directory', 'kimaki.app'],
578
- })
579
-
580
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
581
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
582
-
583
- if (!projectDirectory) {
584
- discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`)
564
+ if (!channelConfig) {
565
+ discordLogger.log(`[BOT_SESSION] No project directory configured for parent channel`)
585
566
  return
586
567
  }
587
568
 
569
+ const projectDirectory = channelConfig.directory
570
+ const channelAppId = channelConfig.appId || undefined
571
+
588
572
  if (channelAppId && channelAppId !== currentAppId) {
589
573
  discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`)
590
574
  return
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { splitMarkdownForDiscord } from './discord-utils.js'
3
+
4
+ describe('splitMarkdownForDiscord', () => {
5
+ test('never returns chunks over the max length with code fences', () => {
6
+ const maxLength = 2000
7
+ const header = '## Summary of Current Architecture\n\n'
8
+ const codeFenceStart = '```\n'
9
+ const codeFenceEnd = '\n```\n'
10
+ const codeLine = 'x'.repeat(180)
11
+ const codeBlock = Array.from({ length: 20 })
12
+ .map(() => codeLine)
13
+ .join('\n')
14
+ const markdown = `${header}${codeFenceStart}${codeBlock}${codeFenceEnd}`
15
+
16
+ const chunks = splitMarkdownForDiscord({ content: markdown, maxLength })
17
+
18
+ expect(chunks.length).toBeGreaterThan(1)
19
+ for (const chunk of chunks) {
20
+ expect(chunk.length).toBeLessThanOrEqual(maxLength)
21
+ }
22
+ })
23
+ })
@@ -4,8 +4,8 @@
4
4
 
5
5
  import { ChannelType, type Message, type TextChannel, type ThreadChannel } from 'discord.js'
6
6
  import { Lexer } from 'marked'
7
- import { extractTagsArrays } from './xml.js'
8
7
  import { formatMarkdownTables } from './format-tables.js'
8
+ import { getChannelDirectory } from './database.js'
9
9
  import { limitHeadingDepth } from './limit-heading-depth.js'
10
10
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
11
11
  import { createLogger } from './logger.js'
@@ -132,8 +132,18 @@ export function splitMarkdownForDiscord({
132
132
  return pieces
133
133
  }
134
134
 
135
+ const closingFence = '```\n'
136
+
135
137
  for (const line of lines) {
136
- const wouldExceed = currentChunk.length + line.text.length > maxLength
138
+ const openingFenceSize =
139
+ currentChunk.length === 0 && (line.inCodeBlock || line.isOpeningFence)
140
+ ? ('```' + line.lang + '\n').length
141
+ : 0
142
+ const lineLength = line.isOpeningFence ? 0 : line.text.length
143
+ const activeFenceOverhead =
144
+ currentLang !== null || openingFenceSize > 0 ? closingFence.length : 0
145
+ const wouldExceed =
146
+ currentChunk.length + openingFenceSize + lineLength + activeFenceOverhead > maxLength
137
147
 
138
148
  if (wouldExceed) {
139
149
  // handle case where single line is longer than maxLength
@@ -195,9 +205,34 @@ export function splitMarkdownForDiscord({
195
205
  }
196
206
  } else {
197
207
  // currentChunk is empty but line still exceeds - shouldn't happen after above check
198
- currentChunk = line.text
199
- if (line.inCodeBlock || line.isOpeningFence) {
200
- currentLang = line.lang
208
+ const openingFence = line.inCodeBlock || line.isOpeningFence
209
+ const openingFenceSize = openingFence ? ('```' + line.lang + '\n').length : 0
210
+ if (line.text.length + openingFenceSize + activeFenceOverhead > maxLength) {
211
+ const fencedOverhead = openingFence
212
+ ? ('```' + line.lang + '\n').length + closingFence.length
213
+ : 0
214
+ const availablePerChunk = Math.max(10, maxLength - fencedOverhead - 50)
215
+ const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock)
216
+ for (const piece of pieces) {
217
+ if (openingFence) {
218
+ chunks.push('```' + line.lang + '\n' + piece + closingFence)
219
+ } else {
220
+ chunks.push(piece)
221
+ }
222
+ }
223
+ currentChunk = ''
224
+ currentLang = null
225
+ } else {
226
+ if (openingFence) {
227
+ currentChunk = '```' + line.lang + '\n'
228
+ if (!line.isOpeningFence) {
229
+ currentChunk += line.text
230
+ }
231
+ currentLang = line.lang
232
+ } else {
233
+ currentChunk = line.text
234
+ currentLang = null
235
+ }
201
236
  }
202
237
  }
203
238
  } else {
@@ -211,6 +246,9 @@ export function splitMarkdownForDiscord({
211
246
  }
212
247
 
213
248
  if (currentChunk) {
249
+ if (currentLang !== null) {
250
+ currentChunk += closingFence
251
+ }
214
252
  chunks.push(currentChunk)
215
253
  }
216
254
 
@@ -291,19 +329,20 @@ export function getKimakiMetadata(textChannel: TextChannel | null): {
291
329
  projectDirectory?: string
292
330
  channelAppId?: string
293
331
  } {
294
- if (!textChannel?.topic) {
332
+ if (!textChannel) {
295
333
  return {}
296
334
  }
297
335
 
298
- const extracted = extractTagsArrays({
299
- xml: textChannel.topic,
300
- tags: ['kimaki.directory', 'kimaki.app'],
301
- })
336
+ const channelConfig = getChannelDirectory(textChannel.id)
302
337
 
303
- const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
304
- const channelAppId = extracted['kimaki.app']?.[0]?.trim()
338
+ if (!channelConfig) {
339
+ return {}
340
+ }
305
341
 
306
- return { projectDirectory, channelAppId }
342
+ return {
343
+ projectDirectory: channelConfig.directory,
344
+ channelAppId: channelConfig.appId || undefined,
345
+ }
307
346
  }
308
347
 
309
348
  /**
@@ -194,11 +194,17 @@ test('splitMarkdownForDiscord adds closing and opening fences when splitting cod
194
194
  [
195
195
  "\`\`\`js
196
196
  line1
197
+ \`\`\`
198
+ ",
199
+ "\`\`\`js
197
200
  line2
198
201
  \`\`\`
199
202
  ",
200
203
  "\`\`\`js
201
204
  line3
205
+ \`\`\`
206
+ ",
207
+ "\`\`\`js
202
208
  line4
203
209
  \`\`\`
204
210
  ",
@@ -234,10 +240,12 @@ test('splitMarkdownForDiscord handles mixed content with code blocks', () => {
234
240
  [
235
241
  "Text before
236
242
  \`\`\`js
237
- code
238
243
  \`\`\`
239
244
  ",
240
- "Text after",
245
+ "\`\`\`js
246
+ code
247
+ \`\`\`
248
+ Text after",
241
249
  ]
242
250
  `)
243
251
  })
@@ -250,6 +258,9 @@ test('splitMarkdownForDiscord handles code block without language', () => {
250
258
  expect(result).toMatchInlineSnapshot(`
251
259
  [
252
260
  "\`\`\`
261
+ \`\`\`
262
+ ",
263
+ "\`\`\`
253
264
  line1
254
265
  \`\`\`
255
266
  ",
@@ -437,10 +448,10 @@ And here is some text after the code block.`
437
448
 
438
449
  export function formatCurrency(amount: number): string {
439
450
  return new Intl.NumberFormat('en-US', {
440
- style: 'currency',
441
451
  \`\`\`
442
452
  ",
443
453
  "\`\`\`typescript
454
+ style: 'currency',
444
455
  currency: 'USD',
445
456
  }).format(amount)
446
457
  }
@@ -31,6 +31,7 @@ import { handleAskQuestionSelectMenu } from './commands/ask-question.js'
31
31
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js'
32
32
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
33
33
  import { handleUserCommand } from './commands/user-command.js'
34
+ import { handleVerbosityCommand } from './commands/verbosity.js'
34
35
  import { createLogger } from './logger.js'
35
36
 
36
37
  const interactionLogger = createLogger('INTERACTION')
@@ -156,6 +157,10 @@ export function registerInteractionHandler({
156
157
  case 'redo':
157
158
  await handleRedoCommand({ command: interaction, appId })
158
159
  return
160
+
161
+ case 'verbosity':
162
+ await handleVerbosityCommand({ command: interaction, appId })
163
+ return
159
164
  }
160
165
 
161
166
  // Handle quick agent commands (ending with -agent suffix, but not the base /agent command)