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
|
@@ -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
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 (!
|
|
50
|
-
return new WorktreeError(
|
|
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
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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;
|
|
@@ -167,3 +195,82 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
|
167
195
|
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
168
196
|
});
|
|
169
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Handle /new-worktree when called inside an existing thread.
|
|
200
|
+
* Attaches a worktree to the current thread, using thread name if no name provided.
|
|
201
|
+
*/
|
|
202
|
+
async function handleWorktreeInThread({ command, appId, thread, }) {
|
|
203
|
+
// Error if thread already has a worktree
|
|
204
|
+
if (getThreadWorktree(thread.id)) {
|
|
205
|
+
await command.editReply('This thread already has a worktree attached.');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Get worktree name from parameter or derive from thread name
|
|
209
|
+
const rawName = command.options.getString('name');
|
|
210
|
+
const worktreeName = rawName ? formatWorktreeName(rawName) : deriveWorktreeNameFromThread(thread.name);
|
|
211
|
+
if (!worktreeName) {
|
|
212
|
+
await command.editReply('Invalid worktree name. Please provide a name or rename the thread.');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Get parent channel for project directory
|
|
216
|
+
const parent = thread.parent;
|
|
217
|
+
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
218
|
+
await command.editReply('Cannot determine parent channel');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const projectDirectory = getProjectDirectoryFromChannel(parent, appId);
|
|
222
|
+
if (errore.isError(projectDirectory)) {
|
|
223
|
+
await command.editReply(projectDirectory.message);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Initialize opencode
|
|
227
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
228
|
+
if (errore.isError(getClient)) {
|
|
229
|
+
await command.editReply(`Failed to initialize OpenCode: ${getClient.message}`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const clientV2 = getOpencodeClientV2(projectDirectory);
|
|
233
|
+
if (!clientV2) {
|
|
234
|
+
await command.editReply('Failed to get OpenCode client');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Check if worktree with this name already exists
|
|
238
|
+
const listResult = await errore.tryAsync({
|
|
239
|
+
try: async () => {
|
|
240
|
+
const response = await clientV2.worktree.list({ directory: projectDirectory });
|
|
241
|
+
return response.data || [];
|
|
242
|
+
},
|
|
243
|
+
catch: (e) => new WorktreeError('Failed to list worktrees', { cause: e }),
|
|
244
|
+
});
|
|
245
|
+
if (errore.isError(listResult)) {
|
|
246
|
+
await command.editReply(listResult.message);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const existingWorktreePath = listResult.find((dir) => dir.endsWith(`/${worktreeName}`));
|
|
250
|
+
if (existingWorktreePath) {
|
|
251
|
+
await command.editReply(`Worktree \`${worktreeName}\` already exists at \`${existingWorktreePath}\``);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// Store pending worktree in database for this existing thread
|
|
255
|
+
createPendingWorktree({
|
|
256
|
+
threadId: thread.id,
|
|
257
|
+
worktreeName,
|
|
258
|
+
projectDirectory,
|
|
259
|
+
});
|
|
260
|
+
// Send status message in thread
|
|
261
|
+
const statusMessage = await thread.send({
|
|
262
|
+
content: `🌳 **Creating worktree: ${worktreeName}**\n⏳ Setting up...`,
|
|
263
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
264
|
+
});
|
|
265
|
+
await command.editReply(`Creating worktree \`${worktreeName}\` for this thread...`);
|
|
266
|
+
// Create worktree in background
|
|
267
|
+
createWorktreeInBackground({
|
|
268
|
+
thread,
|
|
269
|
+
starterMessage: statusMessage,
|
|
270
|
+
worktreeName,
|
|
271
|
+
projectDirectory,
|
|
272
|
+
clientV2,
|
|
273
|
+
}).catch((e) => {
|
|
274
|
+
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
275
|
+
});
|
|
276
|
+
}
|
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();
|
package/dist/discord-bot.js
CHANGED
|
@@ -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
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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;
|
|
@@ -437,21 +430,14 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
437
430
|
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
|
|
438
431
|
return;
|
|
439
432
|
}
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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`);
|
|
433
|
+
// Get directory from database
|
|
434
|
+
const channelConfig = getChannelDirectory(parent.id);
|
|
435
|
+
if (!channelConfig) {
|
|
436
|
+
discordLogger.log(`[BOT_SESSION] No project directory configured for parent channel`);
|
|
453
437
|
return;
|
|
454
438
|
}
|
|
439
|
+
const projectDirectory = channelConfig.directory;
|
|
440
|
+
const channelAppId = channelConfig.appId || undefined;
|
|
455
441
|
if (channelAppId && channelAppId !== currentAppId) {
|
|
456
442
|
discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`);
|
|
457
443
|
return;
|
package/dist/discord-utils.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
276
|
+
if (!textChannel) {
|
|
240
277
|
return {};
|
|
241
278
|
}
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
"
|
|
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') {
|