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.
- package/dist/channel-management.js +6 -15
- package/dist/cli.js +54 -37
- package/dist/commands/permissions.js +21 -5
- package/dist/commands/queue.js +5 -1
- package/dist/commands/resume.js +8 -16
- package/dist/commands/session.js +18 -42
- package/dist/commands/user-command.js +8 -17
- package/dist/commands/verbosity.js +53 -0
- package/dist/commands/worktree-settings.js +2 -2
- package/dist/commands/worktree.js +132 -25
- package/dist/database.js +49 -0
- package/dist/discord-bot.js +24 -38
- package/dist/discord-utils.js +51 -13
- package/dist/discord-utils.test.js +20 -0
- package/dist/escape-backticks.test.js +14 -3
- package/dist/interaction-handler.js +4 -0
- package/dist/session-handler.js +541 -413
- package/package.json +1 -1
- package/src/__snapshots__/first-session-no-info.md +1344 -0
- package/src/__snapshots__/first-session-with-info.md +1350 -0
- package/src/__snapshots__/session-1.md +1344 -0
- package/src/__snapshots__/session-2.md +291 -0
- package/src/__snapshots__/session-3.md +20324 -0
- package/src/__snapshots__/session-with-tools.md +1344 -0
- package/src/channel-management.ts +6 -17
- package/src/cli.ts +63 -45
- package/src/commands/permissions.ts +31 -5
- package/src/commands/queue.ts +5 -1
- package/src/commands/resume.ts +8 -18
- package/src/commands/session.ts +18 -44
- package/src/commands/user-command.ts +8 -19
- package/src/commands/verbosity.ts +71 -0
- package/src/commands/worktree-settings.ts +2 -2
- package/src/commands/worktree.ts +160 -27
- package/src/database.ts +65 -0
- package/src/discord-bot.ts +26 -42
- package/src/discord-utils.test.ts +23 -0
- package/src/discord-utils.ts +52 -13
- package/src/escape-backticks.test.ts +14 -3
- package/src/interaction-handler.ts +5 -0
- package/src/session-handler.ts +669 -436
package/src/commands/worktree.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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 (
|
|
68
|
-
return new WorktreeError('This channel is not configured
|
|
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(
|
|
72
|
-
return new WorktreeError(`Directory does not exist: ${
|
|
85
|
+
if (!fs.existsSync(channelConfig.directory)) {
|
|
86
|
+
return new WorktreeError(`Directory does not exist: ${channelConfig.directory}`)
|
|
73
87
|
}
|
|
74
88
|
|
|
75
|
-
return
|
|
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
|
|
126
|
-
|
|
139
|
+
const channel = command.channel
|
|
140
|
+
if (!channel) {
|
|
141
|
+
await command.editReply('Cannot determine channel')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
127
144
|
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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()
|
package/src/discord-bot.ts
CHANGED
|
@@ -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
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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 (!
|
|
391
|
-
voiceLogger.log(`[IGNORED] Channel #${textChannel.name} has no
|
|
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
|
-
//
|
|
570
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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
|
+
})
|
package/src/discord-utils.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
332
|
+
if (!textChannel) {
|
|
295
333
|
return {}
|
|
296
334
|
}
|
|
297
335
|
|
|
298
|
-
const
|
|
299
|
-
xml: textChannel.topic,
|
|
300
|
-
tags: ['kimaki.directory', 'kimaki.app'],
|
|
301
|
-
})
|
|
336
|
+
const channelConfig = getChannelDirectory(textChannel.id)
|
|
302
337
|
|
|
303
|
-
|
|
304
|
-
|
|
338
|
+
if (!channelConfig) {
|
|
339
|
+
return {}
|
|
340
|
+
}
|
|
305
341
|
|
|
306
|
-
return {
|
|
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
|
-
"
|
|
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)
|