kimaki 0.19.0 → 0.20.1
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/cli-commands/project.js +85 -87
- package/dist/commands/agent.js +51 -4
- package/dist/commands/user-command.js +47 -5
- package/dist/discord-bot.js +36 -27
- package/dist/discord-utils.js +18 -1
- package/dist/message-formatting.js +48 -16
- package/dist/message-formatting.test.js +39 -1
- package/dist/session-handler/thread-session-runtime.js +40 -19
- package/dist/store.js +1 -0
- package/dist/system-message.js +16 -4
- package/dist/system-message.test.js +16 -4
- package/package.json +3 -3
- package/skills/egaki/SKILL.md +17 -0
- package/skills/goke/SKILL.md +17 -0
- package/skills/holocron/SKILL.md +66 -1
- package/skills/sigillo/SKILL.md +35 -4
- package/skills/zele/SKILL.md +13 -0
- package/src/cli-commands/project.ts +99 -87
- package/src/commands/agent.ts +64 -3
- package/src/commands/user-command.ts +65 -4
- package/src/discord-bot.ts +43 -28
- package/src/discord-utils.ts +24 -0
- package/src/message-formatting.test.ts +60 -1
- package/src/message-formatting.ts +59 -15
- package/src/session-handler/thread-session-runtime.ts +49 -18
- package/src/store.ts +8 -0
- package/src/system-message.test.ts +16 -4
- package/src/system-message.ts +16 -4
|
@@ -56,74 +56,7 @@ cli
|
|
|
56
56
|
void client.login(botToken);
|
|
57
57
|
});
|
|
58
58
|
cliLogger.log('Finding guild...');
|
|
59
|
-
|
|
60
|
-
let guild;
|
|
61
|
-
if (options.guild) {
|
|
62
|
-
const guildId = String(options.guild);
|
|
63
|
-
const foundGuild = client.guilds.cache.get(guildId);
|
|
64
|
-
if (!foundGuild) {
|
|
65
|
-
cliLogger.log('Guild not found');
|
|
66
|
-
cliLogger.error(`Guild not found: ${guildId}`);
|
|
67
|
-
void client.destroy();
|
|
68
|
-
process.exit(EXIT_NO_RESTART);
|
|
69
|
-
}
|
|
70
|
-
guild = foundGuild;
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
const existingChannelId = await (await getDb()).query.channel_directories.findFirst({
|
|
74
|
-
where: { channel_type: 'text' },
|
|
75
|
-
orderBy: { created_at: 'desc' },
|
|
76
|
-
columns: { channel_id: true },
|
|
77
|
-
}).then((row) => row?.channel_id);
|
|
78
|
-
if (existingChannelId) {
|
|
79
|
-
try {
|
|
80
|
-
const ch = await client.channels.fetch(existingChannelId);
|
|
81
|
-
if (ch && !ch.isDMBased()) {
|
|
82
|
-
guild = ch.guild;
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
throw new Error('Channel has no guild');
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.stack : String(error));
|
|
90
|
-
let firstGuild = client.guilds.cache.first();
|
|
91
|
-
if (!firstGuild) {
|
|
92
|
-
// Cache might be empty, try fetching guilds from API
|
|
93
|
-
const fetched = await client.guilds.fetch();
|
|
94
|
-
const firstOAuth2Guild = fetched.first();
|
|
95
|
-
if (firstOAuth2Guild) {
|
|
96
|
-
firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (!firstGuild) {
|
|
100
|
-
cliLogger.log('No guild found');
|
|
101
|
-
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
102
|
-
void client.destroy();
|
|
103
|
-
process.exit(EXIT_NO_RESTART);
|
|
104
|
-
}
|
|
105
|
-
guild = firstGuild;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
let firstGuild = client.guilds.cache.first();
|
|
110
|
-
if (!firstGuild) {
|
|
111
|
-
// Cache might be empty, try fetching guilds from API
|
|
112
|
-
const fetched = await client.guilds.fetch();
|
|
113
|
-
const firstOAuth2Guild = fetched.first();
|
|
114
|
-
if (firstOAuth2Guild) {
|
|
115
|
-
firstGuild = await client.guilds.fetch(firstOAuth2Guild.id);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (!firstGuild) {
|
|
119
|
-
cliLogger.log('No guild found');
|
|
120
|
-
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
121
|
-
void client.destroy();
|
|
122
|
-
process.exit(EXIT_NO_RESTART);
|
|
123
|
-
}
|
|
124
|
-
guild = firstGuild;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
59
|
+
const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild });
|
|
127
60
|
// Check if channel already exists in this guild
|
|
128
61
|
cliLogger.log('Checking for existing channel...');
|
|
129
62
|
try {
|
|
@@ -353,25 +286,7 @@ cli
|
|
|
353
286
|
client.once(Events.Error, reject);
|
|
354
287
|
client.login(botToken).catch(reject);
|
|
355
288
|
});
|
|
356
|
-
|
|
357
|
-
if (options.guild) {
|
|
358
|
-
const found = client.guilds.cache.get(options.guild);
|
|
359
|
-
if (!found) {
|
|
360
|
-
cliLogger.error(`Guild not found: ${options.guild}`);
|
|
361
|
-
void client.destroy();
|
|
362
|
-
process.exit(EXIT_NO_RESTART);
|
|
363
|
-
}
|
|
364
|
-
guild = found;
|
|
365
|
-
}
|
|
366
|
-
else {
|
|
367
|
-
const first = client.guilds.cache.first();
|
|
368
|
-
if (!first) {
|
|
369
|
-
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
370
|
-
void client.destroy();
|
|
371
|
-
process.exit(EXIT_NO_RESTART);
|
|
372
|
-
}
|
|
373
|
-
guild = first;
|
|
374
|
-
}
|
|
289
|
+
const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild });
|
|
375
290
|
const { textChannelId, channelName } = await createProjectChannels({
|
|
376
291
|
guild,
|
|
377
292
|
projectDirectory,
|
|
@@ -383,4 +298,87 @@ cli
|
|
|
383
298
|
cliLogger.log(channelUrl);
|
|
384
299
|
process.exit(0);
|
|
385
300
|
});
|
|
301
|
+
// Resolve the guild for project add/create commands. In gateway mode the
|
|
302
|
+
// guild cache only contains authorized guilds, so picking from cache is safe.
|
|
303
|
+
// The old approach fetched an existing channel to infer the guild, but that
|
|
304
|
+
// breaks when the channel belongs to a different guild (e.g. old self-hosted
|
|
305
|
+
// bot channels) and the gateway proxy rejects the REST call. This led to a
|
|
306
|
+
// non-deterministic fallback that picked the wrong guild.
|
|
307
|
+
async function resolveGuildForProjectCommand({ client, guildIdOverride }) {
|
|
308
|
+
if (guildIdOverride) {
|
|
309
|
+
const found = client.guilds.cache.get(guildIdOverride);
|
|
310
|
+
if (!found) {
|
|
311
|
+
cliLogger.error(`Guild not found: ${guildIdOverride}`);
|
|
312
|
+
void client.destroy();
|
|
313
|
+
process.exit(EXIT_NO_RESTART);
|
|
314
|
+
}
|
|
315
|
+
return found;
|
|
316
|
+
}
|
|
317
|
+
// Try existing channel lookup to find the guild the user already has channels in.
|
|
318
|
+
// This handles multi-guild setups where we want to add to the same guild.
|
|
319
|
+
const db = await getDb();
|
|
320
|
+
const existingChannels = await db.query.channel_directories.findMany({
|
|
321
|
+
where: { channel_type: 'text' },
|
|
322
|
+
orderBy: { created_at: 'desc' },
|
|
323
|
+
columns: { channel_id: true },
|
|
324
|
+
limit: 20,
|
|
325
|
+
});
|
|
326
|
+
// Log available guilds for debugging guild selection issues
|
|
327
|
+
const cachedGuilds = Array.from(client.guilds.cache.values());
|
|
328
|
+
cliLogger.debug(`Guilds in cache (${cachedGuilds.length}): ${cachedGuilds.map((g) => `${g.name} (${g.id})`).join(', ')}`);
|
|
329
|
+
// When multiple guilds are available, find which guild has the most
|
|
330
|
+
// existing channels. The user's main guild will have far more channels
|
|
331
|
+
// than a test/demo guild.
|
|
332
|
+
const guildHits = new Map();
|
|
333
|
+
for (const row of existingChannels) {
|
|
334
|
+
try {
|
|
335
|
+
const ch = await client.channels.fetch(row.channel_id);
|
|
336
|
+
if (ch && !ch.isDMBased()) {
|
|
337
|
+
const entry = guildHits.get(ch.guild.id);
|
|
338
|
+
if (entry) {
|
|
339
|
+
entry.count++;
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
guildHits.set(ch.guild.id, { guild: ch.guild, count: 1 });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// Channel might be in a different guild (gateway proxy rejects) or deleted, skip
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (guildHits.size > 0) {
|
|
351
|
+
// Pick the guild with the most channels
|
|
352
|
+
const best = Array.from(guildHits.values()).sort((a, b) => b.count - a.count)[0];
|
|
353
|
+
cliLogger.debug(`Guild channel counts: ${Array.from(guildHits.values()).map((e) => `${e.guild.name} (${e.guild.id}): ${e.count}`).join(', ')}`);
|
|
354
|
+
cliLogger.debug(`Selected guild: ${best.guild.name} (${best.guild.id}) with ${best.count} channels`);
|
|
355
|
+
return best.guild;
|
|
356
|
+
}
|
|
357
|
+
cliLogger.debug('Could not resolve guild from existing channels, falling back to cache');
|
|
358
|
+
// If only one guild in cache, use it directly (common case).
|
|
359
|
+
// If multiple guilds, error out and ask the user to specify --guild
|
|
360
|
+
// since we can't determine which one to use.
|
|
361
|
+
if (cachedGuilds.length === 1) {
|
|
362
|
+
return cachedGuilds[0];
|
|
363
|
+
}
|
|
364
|
+
if (cachedGuilds.length > 1) {
|
|
365
|
+
cliLogger.error(`Multiple guilds found. Use --guild to specify which one:\n${cachedGuilds.map((g) => ` ${g.id} ${g.name}`).join('\n')}`);
|
|
366
|
+
void client.destroy();
|
|
367
|
+
process.exit(EXIT_NO_RESTART);
|
|
368
|
+
}
|
|
369
|
+
// Cache empty, try fetching
|
|
370
|
+
const fetched = await client.guilds.fetch();
|
|
371
|
+
if (fetched.size === 1) {
|
|
372
|
+
const firstOAuth2Guild = fetched.first();
|
|
373
|
+
return await client.guilds.fetch(firstOAuth2Guild.id);
|
|
374
|
+
}
|
|
375
|
+
if (fetched.size > 1) {
|
|
376
|
+
cliLogger.error(`Multiple guilds found. Use --guild to specify which one:\n${Array.from(fetched.values()).map((g) => ` ${g.id} ${g.name}`).join('\n')}`);
|
|
377
|
+
void client.destroy();
|
|
378
|
+
process.exit(EXIT_NO_RESTART);
|
|
379
|
+
}
|
|
380
|
+
cliLogger.error('No guild found. Add the bot to a server first.');
|
|
381
|
+
void client.destroy();
|
|
382
|
+
process.exit(EXIT_NO_RESTART);
|
|
383
|
+
}
|
|
386
384
|
export default cli;
|
package/dist/commands/agent.js
CHANGED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
// the prompt is sent with that agent and the session keeps that agent afterwards.
|
|
5
5
|
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
|
-
import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, } from '../database.js';
|
|
7
|
+
import { setChannelAgent, setSessionAgent, clearSessionModel, getThreadSession, getSessionAgent, getChannelAgent, getChannelWorktreesEnabled, } from '../database.js';
|
|
8
8
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
9
9
|
import { resolveTextChannel, resolveWorkingDirectory, getKimakiMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
10
10
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
11
11
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
12
12
|
import { getCurrentModelInfo } from './model.js';
|
|
13
|
+
import { isGitRepositoryRoot } from '../worktrees.js';
|
|
14
|
+
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
|
|
15
|
+
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
16
|
+
import { store } from '../store.js';
|
|
13
17
|
const agentLogger = createLogger(LogPrefix.AGENT);
|
|
14
18
|
const AGENT_CONTEXT_TTL_MS = 10 * 60 * 1000;
|
|
15
19
|
const pendingAgentContexts = new Map();
|
|
@@ -429,22 +433,65 @@ async function handleQuickAgentWithPrompt({ command, appId, fallbackAgentName, p
|
|
|
429
433
|
return;
|
|
430
434
|
}
|
|
431
435
|
await command.deferReply();
|
|
436
|
+
// Check if worktrees should be enabled (CLI flag OR channel setting),
|
|
437
|
+
// mirroring the logic in discord-bot.ts message handler.
|
|
438
|
+
const wantsWorktrees = store.getState().useWorktrees ||
|
|
439
|
+
(await getChannelWorktreesEnabled(channel.id));
|
|
440
|
+
const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
|
|
441
|
+
if (wantsWorktrees && !shouldUseWorktrees) {
|
|
442
|
+
agentLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
|
|
443
|
+
}
|
|
444
|
+
const baseThreadName = prompt.slice(0, 80);
|
|
445
|
+
const threadName = shouldUseWorktrees
|
|
446
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
447
|
+
: baseThreadName;
|
|
432
448
|
const starterMessage = await channel.send({
|
|
433
449
|
content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
|
|
434
450
|
flags: SILENT_MESSAGE_FLAGS,
|
|
435
451
|
});
|
|
436
452
|
const thread = await starterMessage.startThread({
|
|
437
|
-
name:
|
|
453
|
+
name: threadName.slice(0, 80),
|
|
438
454
|
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
439
455
|
reason: `${resolvedAgentName} agent prompt`,
|
|
440
456
|
});
|
|
441
457
|
await thread.members.add(command.user.id);
|
|
442
|
-
|
|
458
|
+
// Create worktree in background if enabled, same as discord-bot.ts
|
|
459
|
+
let worktreePromise;
|
|
460
|
+
if (shouldUseWorktrees) {
|
|
461
|
+
const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50));
|
|
462
|
+
agentLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
|
|
463
|
+
const worktreeStatusMessage = await thread
|
|
464
|
+
.send({
|
|
465
|
+
content: worktreeCreatingMessage(worktreeName),
|
|
466
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
467
|
+
})
|
|
468
|
+
.catch(() => undefined);
|
|
469
|
+
worktreePromise = createWorktreeInBackground({
|
|
470
|
+
thread,
|
|
471
|
+
starterMessage: worktreeStatusMessage,
|
|
472
|
+
worktreeName,
|
|
473
|
+
projectDirectory,
|
|
474
|
+
rest: command.client.rest,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
const sessionDirectory = await (async () => {
|
|
478
|
+
if (!worktreePromise)
|
|
479
|
+
return projectDirectory;
|
|
480
|
+
const result = await worktreePromise;
|
|
481
|
+
if (result instanceof Error)
|
|
482
|
+
return projectDirectory;
|
|
483
|
+
return result;
|
|
484
|
+
})();
|
|
485
|
+
await command
|
|
486
|
+
.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
|
|
487
|
+
.catch(() => {
|
|
488
|
+
agentLogger.warn('[AGENT] Failed to edit quick-agent reply, continuing session');
|
|
489
|
+
});
|
|
443
490
|
const runtime = getOrCreateRuntime({
|
|
444
491
|
threadId: thread.id,
|
|
445
492
|
thread,
|
|
446
493
|
projectDirectory,
|
|
447
|
-
sdkDirectory:
|
|
494
|
+
sdkDirectory: sessionDirectory,
|
|
448
495
|
channelId: channel.id,
|
|
449
496
|
appId,
|
|
450
497
|
});
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// User-defined OpenCode command handler.
|
|
2
2
|
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
|
-
import { ChannelType, MessageFlags, } from 'discord.js';
|
|
3
|
+
import { ChannelType, MessageFlags, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
4
4
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
5
5
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
6
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
|
-
import { getChannelDirectory, getThreadSession } from '../database.js';
|
|
7
|
+
import { getChannelDirectory, getChannelWorktreesEnabled, getThreadSession, } from '../database.js';
|
|
8
8
|
import { store } from '../store.js';
|
|
9
|
+
import { isGitRepositoryRoot } from '../worktrees.js';
|
|
10
|
+
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage, } from './new-worktree.js';
|
|
11
|
+
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
9
12
|
import fs from 'node:fs';
|
|
10
13
|
const userCommandLogger = createLogger(LogPrefix.USER_CMD);
|
|
11
14
|
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
@@ -107,23 +110,62 @@ export const handleUserCommand = async ({ command, appId, }) => {
|
|
|
107
110
|
}
|
|
108
111
|
else if (textChannel) {
|
|
109
112
|
// Running in text channel - create a new thread
|
|
113
|
+
// Check if worktrees should be enabled (CLI flag OR channel setting),
|
|
114
|
+
// mirroring the logic in discord-bot.ts message handler.
|
|
115
|
+
const wantsWorktrees = store.getState().useWorktrees ||
|
|
116
|
+
(await getChannelWorktreesEnabled(textChannel.id));
|
|
117
|
+
const shouldUseWorktrees = wantsWorktrees && (await isGitRepositoryRoot(projectDirectory));
|
|
118
|
+
if (wantsWorktrees && !shouldUseWorktrees) {
|
|
119
|
+
userCommandLogger.warn(`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`);
|
|
120
|
+
}
|
|
121
|
+
const baseThreadName = commandInvocation.slice(0, DISCORD_THREAD_NAME_LIMIT);
|
|
122
|
+
const threadName = shouldUseWorktrees
|
|
123
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
124
|
+
: baseThreadName;
|
|
110
125
|
const starterMessage = await textChannel.send({
|
|
111
126
|
content: threadOpeningMessage,
|
|
112
127
|
flags: SILENT_MESSAGE_FLAGS,
|
|
113
128
|
});
|
|
114
129
|
const newThread = await starterMessage.startThread({
|
|
115
|
-
name:
|
|
116
|
-
autoArchiveDuration:
|
|
130
|
+
name: threadName.slice(0, DISCORD_THREAD_NAME_LIMIT),
|
|
131
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
117
132
|
reason: `OpenCode command: ${commandName}`,
|
|
118
133
|
});
|
|
119
134
|
// Add user to thread so it appears in their sidebar
|
|
120
135
|
await newThread.members.add(command.user.id);
|
|
136
|
+
// Create worktree in background if enabled, same as discord-bot.ts
|
|
137
|
+
let worktreePromise;
|
|
138
|
+
if (shouldUseWorktrees) {
|
|
139
|
+
const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50));
|
|
140
|
+
userCommandLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`);
|
|
141
|
+
const worktreeStatusMessage = await newThread
|
|
142
|
+
.send({
|
|
143
|
+
content: worktreeCreatingMessage(worktreeName),
|
|
144
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
145
|
+
})
|
|
146
|
+
.catch(() => undefined);
|
|
147
|
+
worktreePromise = createWorktreeInBackground({
|
|
148
|
+
thread: newThread,
|
|
149
|
+
starterMessage: worktreeStatusMessage,
|
|
150
|
+
worktreeName,
|
|
151
|
+
projectDirectory,
|
|
152
|
+
rest: command.client.rest,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const sessionDirectory = await (async () => {
|
|
156
|
+
if (!worktreePromise)
|
|
157
|
+
return projectDirectory;
|
|
158
|
+
const result = await worktreePromise;
|
|
159
|
+
if (result instanceof Error)
|
|
160
|
+
return projectDirectory;
|
|
161
|
+
return result;
|
|
162
|
+
})();
|
|
121
163
|
await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
|
|
122
164
|
const runtime = getOrCreateRuntime({
|
|
123
165
|
threadId: newThread.id,
|
|
124
166
|
thread: newThread,
|
|
125
167
|
projectDirectory,
|
|
126
|
-
sdkDirectory:
|
|
168
|
+
sdkDirectory: sessionDirectory,
|
|
127
169
|
channelId: textChannel.id,
|
|
128
170
|
appId,
|
|
129
171
|
});
|
package/dist/discord-bot.js
CHANGED
|
@@ -173,6 +173,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
173
173
|
if (!discordClient) {
|
|
174
174
|
discordClient = await createDiscordClient();
|
|
175
175
|
}
|
|
176
|
+
store.setState({ useWorktrees: Boolean(useWorktrees) });
|
|
176
177
|
let currentAppId = appId;
|
|
177
178
|
const setupHandlers = async (c) => {
|
|
178
179
|
discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
|
|
@@ -260,9 +261,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
260
261
|
discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
|
|
261
262
|
if (state.attempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
262
263
|
discordLogger.error(`[GATEWAY] Shard ${shardId} exceeded ${MAX_RECONNECT_ATTEMPTS} reconnect attempts, self-restarting`);
|
|
263
|
-
// Self-restart: cleanup then
|
|
264
|
-
//
|
|
265
|
-
// only restarts when the wrapper is the parent).
|
|
264
|
+
// Self-restart: cleanup, then exit non-zero so the bin.ts wrapper
|
|
265
|
+
// restarts us. Without the wrapper this exits after logging a warning.
|
|
266
266
|
void selfRestart('gateway-reconnect-limit');
|
|
267
267
|
}
|
|
268
268
|
});
|
|
@@ -332,15 +332,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
332
332
|
return;
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
|
-
//
|
|
336
|
-
//
|
|
335
|
+
// Detect messages that start with a mention of another user (not the bot).
|
|
336
|
+
// In channels these are fully ignored. In threads they are added to the
|
|
337
|
+
// session context without triggering the AI (noReply), so the agent sees
|
|
338
|
+
// user-to-user conversation on the next real turn.
|
|
337
339
|
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (mentionedUserId !== discordClient.user?.id) {
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
340
|
+
const isLeadingMentionToOtherUser = leadingMentionMatch &&
|
|
341
|
+
leadingMentionMatch[1] !== discordClient.user?.id;
|
|
344
342
|
if (message.partial) {
|
|
345
343
|
discordLogger.log(`Fetching partial message ${message.id}`);
|
|
346
344
|
const fetched = await message.fetch()
|
|
@@ -354,6 +352,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
354
352
|
// When mention mode is enabled, users without Kimaki role can message
|
|
355
353
|
// without getting a permission error - we just silently ignore.
|
|
356
354
|
const channel = message.channel;
|
|
355
|
+
// In text channels, messages starting with a mention to another user
|
|
356
|
+
// are fully ignored before any permission or mention-mode checks.
|
|
357
|
+
// This prevents permission-error replies for user-to-user conversation.
|
|
358
|
+
if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
357
361
|
if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
|
|
358
362
|
const mentionModeEnabled = await getChannelMentionMode(channel.id);
|
|
359
363
|
if (mentionModeEnabled) {
|
|
@@ -435,6 +439,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
435
439
|
discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
|
|
436
440
|
return;
|
|
437
441
|
}
|
|
442
|
+
// Context-only messages (user-to-user replies) can't be stored without
|
|
443
|
+
// an existing session. Skip early to avoid creating a runtime or running
|
|
444
|
+
// preprocessing for nothing.
|
|
445
|
+
if (isLeadingMentionToOtherUser && !hasExistingSession) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
438
448
|
const parent = thread.parent;
|
|
439
449
|
let projectDirectory;
|
|
440
450
|
if (parent) {
|
|
@@ -509,7 +519,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
509
519
|
const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
|
|
510
520
|
? extractBtwSuffix(message.content || '')
|
|
511
521
|
: null;
|
|
512
|
-
if (btwResult?.forceBtw && projectDirectory) {
|
|
522
|
+
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
513
523
|
const result = await forkSessionToBtwThread({
|
|
514
524
|
sourceThread: thread,
|
|
515
525
|
projectDirectory,
|
|
@@ -559,7 +569,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
559
569
|
appId: currentAppId,
|
|
560
570
|
});
|
|
561
571
|
// Cancel interactive UI when a real user sends a message.
|
|
562
|
-
|
|
572
|
+
// Context-only messages (user-to-user replies) should not interrupt
|
|
573
|
+
// the active run or dismiss pending UI.
|
|
574
|
+
if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
|
|
563
575
|
cancelPendingActionButtons(thread.id);
|
|
564
576
|
cancelHtmlActionsForThread(thread.id);
|
|
565
577
|
const dismissedPermission = await cancelPendingPermission(thread.id);
|
|
@@ -594,6 +606,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
594
606
|
model: cliInjectedModel,
|
|
595
607
|
permissions: cliInjectedPermissions,
|
|
596
608
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
609
|
+
noReply: isLeadingMentionToOtherUser || undefined,
|
|
597
610
|
sessionStartSource: sessionStartSource
|
|
598
611
|
? {
|
|
599
612
|
scheduleKind: sessionStartSource.scheduleKind,
|
|
@@ -1152,7 +1165,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1152
1165
|
discordLogger.error('Failed to write heap snapshot:', e instanceof Error ? e.message : String(e));
|
|
1153
1166
|
});
|
|
1154
1167
|
});
|
|
1155
|
-
// Self-restart:
|
|
1168
|
+
// Self-restart: exit with code 1 so the bin.ts wrapper restarts us with
|
|
1169
|
+
// exponential backoff and crash-loop detection. When running without the
|
|
1170
|
+
// wrapper (e.g. `tsx src/cli.ts`), the process just exits and the user
|
|
1171
|
+
// sees the non-zero exit code — they should use `tsx src/bin.ts` instead
|
|
1172
|
+
// for auto-restart support. The previous detached-spawn fallback was
|
|
1173
|
+
// unreliable: process.exit(0) could kill the child before it started,
|
|
1174
|
+
// there was no backoff between restarts, and spawn failures were silent.
|
|
1156
1175
|
let selfRestarting = false;
|
|
1157
1176
|
async function selfRestart(reason) {
|
|
1158
1177
|
if (selfRestarting) {
|
|
@@ -1167,20 +1186,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1167
1186
|
catch (error) {
|
|
1168
1187
|
voiceLogger.error(`[${reason}] Error during shutdown:`, error);
|
|
1169
1188
|
}
|
|
1170
|
-
if (process.env.__KIMAKI_CHILD) {
|
|
1171
|
-
discordLogger.
|
|
1172
|
-
process.exit(1);
|
|
1189
|
+
if (!process.env.__KIMAKI_CHILD) {
|
|
1190
|
+
discordLogger.warn('No restart wrapper detected. Run via `tsx src/bin.ts` (dev) or `kimaki` (npm) for auto-restart on crash.');
|
|
1173
1191
|
}
|
|
1174
|
-
|
|
1175
|
-
const env = { ...process.env };
|
|
1176
|
-
delete env.__KIMAKI_CHILD;
|
|
1177
|
-
spawn(process.argv[0], [...process.execArgv, ...process.argv.slice(1)], {
|
|
1178
|
-
stdio: 'inherit',
|
|
1179
|
-
detached: true,
|
|
1180
|
-
cwd: process.cwd(),
|
|
1181
|
-
env,
|
|
1182
|
-
}).unref();
|
|
1183
|
-
process.exit(0);
|
|
1192
|
+
process.exit(1);
|
|
1184
1193
|
}
|
|
1185
1194
|
process.on('SIGUSR2', () => {
|
|
1186
1195
|
discordLogger.log('Received SIGUSR2, restarting after cleanup...');
|
package/dist/discord-utils.js
CHANGED
|
@@ -590,14 +590,31 @@ export async function resolveWorkingDirectory({ channel, }) {
|
|
|
590
590
|
workingDirectory,
|
|
591
591
|
};
|
|
592
592
|
}
|
|
593
|
+
// Discord upload size limits per server boost tier (bytes).
|
|
594
|
+
// Bots default to 25 MB; boosted servers raise the ceiling.
|
|
595
|
+
export const DISCORD_DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024;
|
|
593
596
|
/**
|
|
594
597
|
* Upload files to a Discord thread/channel in a single message.
|
|
595
598
|
* Sending all files in one message causes Discord to display images in a grid layout.
|
|
599
|
+
*
|
|
600
|
+
* Files are validated against the Discord upload size limit before reading them
|
|
601
|
+
* into memory. Pass `maxFileSize` if you know the guild's boost tier limit;
|
|
602
|
+
* otherwise the conservative 25 MB bot default is used.
|
|
596
603
|
*/
|
|
597
|
-
export async function uploadFilesToDiscord({ threadId, botToken, files, }) {
|
|
604
|
+
export async function uploadFilesToDiscord({ threadId, botToken, files, maxFileSize = DISCORD_DEFAULT_MAX_FILE_SIZE, }) {
|
|
598
605
|
if (files.length === 0) {
|
|
599
606
|
return;
|
|
600
607
|
}
|
|
608
|
+
// Fail fast: check file sizes before reading anything into memory
|
|
609
|
+
const sizeLimit = maxFileSize ?? DISCORD_DEFAULT_MAX_FILE_SIZE;
|
|
610
|
+
for (const file of files) {
|
|
611
|
+
const stat = fs.statSync(file);
|
|
612
|
+
if (stat.size > sizeLimit) {
|
|
613
|
+
const fileMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
614
|
+
const limitMB = (sizeLimit / 1024 / 1024).toFixed(0);
|
|
615
|
+
throw new Error(`File "${path.basename(file)}" is ${fileMB} MB, which exceeds Discord's ${limitMB} MB upload limit`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
601
618
|
// Build attachments array for all files
|
|
602
619
|
const attachments = files.map((file, index) => ({
|
|
603
620
|
id: index,
|
|
@@ -266,6 +266,44 @@ export async function getFileAttachments(message) {
|
|
|
266
266
|
return results.filter((r) => r !== null);
|
|
267
267
|
}
|
|
268
268
|
const MAX_BASH_COMMAND_INLINE_LENGTH = 100;
|
|
269
|
+
/**
|
|
270
|
+
* Format the inline title for a bash tool part. Handles three cases:
|
|
271
|
+
* 1. Short single-line command → show full command
|
|
272
|
+
* 2. Long/multiline command with description → show description
|
|
273
|
+
* 3. Long/multiline command without description → truncate first line of command
|
|
274
|
+
*
|
|
275
|
+
* The description field was removed from the opencode v2 bash tool schema but
|
|
276
|
+
* kimaki's system prompt instructs models to always send it as an extra field.
|
|
277
|
+
* Case 3 is the fallback when a model omits it.
|
|
278
|
+
*/
|
|
279
|
+
export function formatBashToolTitle({ command, description, stateTitle, }) {
|
|
280
|
+
if (!command && !description && !stateTitle)
|
|
281
|
+
return '';
|
|
282
|
+
const isSingleLine = !command.includes('\n');
|
|
283
|
+
// Find first non-empty line to handle commands with leading blank lines
|
|
284
|
+
const firstMeaningfulLine = command
|
|
285
|
+
.split('\n')
|
|
286
|
+
.find((line) => line.trim().length > 0)
|
|
287
|
+
?.trimStart() ?? '';
|
|
288
|
+
if (command && isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
|
|
289
|
+
return ` _${escapeInlineMarkdown(command)}_`;
|
|
290
|
+
}
|
|
291
|
+
if (description) {
|
|
292
|
+
return ` _${escapeInlineMarkdown(description)}_`;
|
|
293
|
+
}
|
|
294
|
+
if (firstMeaningfulLine.length > 0) {
|
|
295
|
+
const needsTruncation = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH;
|
|
296
|
+
const base = needsTruncation
|
|
297
|
+
? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH)
|
|
298
|
+
: firstMeaningfulLine;
|
|
299
|
+
// Always add ellipsis when showing a partial command (multiline or length-truncated)
|
|
300
|
+
return ` _${escapeInlineMarkdown(base)}…_`;
|
|
301
|
+
}
|
|
302
|
+
if (stateTitle) {
|
|
303
|
+
return ` _${escapeInlineMarkdown(stateTitle)}_`;
|
|
304
|
+
}
|
|
305
|
+
return '';
|
|
306
|
+
}
|
|
269
307
|
export function getToolSummaryText(part) {
|
|
270
308
|
if (part.type !== 'tool')
|
|
271
309
|
return '';
|
|
@@ -449,12 +487,10 @@ export function formatPart(part, prefix) {
|
|
|
449
487
|
}
|
|
450
488
|
const command = part.state.input?.command || '';
|
|
451
489
|
const description = part.state.input?.description || '';
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
? ` _${escapeInlineMarkdown(description)}_`
|
|
457
|
-
: '';
|
|
490
|
+
const toolTitle = formatBashToolTitle({
|
|
491
|
+
command,
|
|
492
|
+
description,
|
|
493
|
+
});
|
|
458
494
|
return `┣ ${pfx}bash${toolTitle}`;
|
|
459
495
|
}
|
|
460
496
|
const summaryText = getToolSummaryText(part);
|
|
@@ -466,16 +502,12 @@ export function formatPart(part, prefix) {
|
|
|
466
502
|
else if (part.tool === 'bash') {
|
|
467
503
|
const command = part.state.input?.command || '';
|
|
468
504
|
const description = part.state.input?.description || '';
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
else if (stateTitle) {
|
|
477
|
-
toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
|
|
478
|
-
}
|
|
505
|
+
const formatted = formatBashToolTitle({
|
|
506
|
+
command,
|
|
507
|
+
description,
|
|
508
|
+
stateTitle,
|
|
509
|
+
});
|
|
510
|
+
toolTitle = formatted.startsWith(' ') ? formatted.slice(1) : formatted;
|
|
479
511
|
}
|
|
480
512
|
else if (stateTitle) {
|
|
481
513
|
toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from 'vitest';
|
|
2
|
-
import { formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
|
|
2
|
+
import { formatBashToolTitle, formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
|
|
3
3
|
describe('formatPart', () => {
|
|
4
4
|
test('callout text does not get ⬥ prefix', () => {
|
|
5
5
|
const part = {
|
|
@@ -42,6 +42,44 @@ describe('formatPart', () => {
|
|
|
42
42
|
`);
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
|
+
describe('formatBashToolTitle', () => {
|
|
46
|
+
test('short single-line command shown in full', () => {
|
|
47
|
+
expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`);
|
|
48
|
+
});
|
|
49
|
+
test('multiline command without description truncates to first line', () => {
|
|
50
|
+
expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo hello…_"`);
|
|
51
|
+
});
|
|
52
|
+
test('long single-line command is truncated with ellipsis', () => {
|
|
53
|
+
const longCommand = 'a'.repeat(150);
|
|
54
|
+
const result = formatBashToolTitle({ command: longCommand });
|
|
55
|
+
expect(result).toContain('…');
|
|
56
|
+
expect(result.length).toBeLessThan(150);
|
|
57
|
+
});
|
|
58
|
+
test('description is preferred over truncated command when present', () => {
|
|
59
|
+
expect(formatBashToolTitle({
|
|
60
|
+
command: 'echo hello\necho world',
|
|
61
|
+
description: 'Print greeting',
|
|
62
|
+
})).toMatchInlineSnapshot(`" _Print greeting_"`);
|
|
63
|
+
});
|
|
64
|
+
test('stateTitle used as last resort', () => {
|
|
65
|
+
expect(formatBashToolTitle({ command: '', stateTitle: 'Running tests' })).toMatchInlineSnapshot(`" _Running tests_"`);
|
|
66
|
+
});
|
|
67
|
+
test('empty inputs return empty string', () => {
|
|
68
|
+
expect(formatBashToolTitle({ command: '' })).toBe('');
|
|
69
|
+
});
|
|
70
|
+
test('leading blank line skipped, uses first meaningful line', () => {
|
|
71
|
+
expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
|
|
72
|
+
});
|
|
73
|
+
test('whitespace-only first line skipped', () => {
|
|
74
|
+
expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm test…_"`);
|
|
75
|
+
});
|
|
76
|
+
test('no description field (new opencode) with multiline command', () => {
|
|
77
|
+
// This is the exact scenario that was broken: opencode removed `description`
|
|
78
|
+
// from the bash tool schema, so multiline commands rendered as just "┣ bash"
|
|
79
|
+
const command = 'git diff HEAD~1 --stat && git log --oneline -5';
|
|
80
|
+
expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -5…_"`);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
45
83
|
describe('formatTodoList', () => {
|
|
46
84
|
test('formats active todo with monospace numbers', () => {
|
|
47
85
|
const part = {
|