kimaki 0.18.0 → 0.20.0
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/cli-commands/send.js +34 -10
- package/dist/cli-commands/session.js +4 -1
- package/dist/commands/agent.js +51 -4
- package/dist/discord-bot.js +24 -10
- package/dist/markdown.js +59 -4
- package/dist/markdown.test.js +73 -2
- package/dist/message-formatting.js +46 -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/package.json +4 -4
- package/skills/goke/SKILL.md +17 -0
- package/skills/holocron/SKILL.md +66 -1
- package/skills/sigillo/SKILL.md +34 -0
- package/skills/zele/SKILL.md +13 -0
- package/src/cli-commands/project.ts +99 -87
- package/src/cli-commands/send.ts +37 -13
- package/src/cli-commands/session.ts +5 -2
- package/src/commands/agent.ts +64 -3
- package/src/discord-bot.ts +29 -10
- package/src/markdown.test.ts +89 -2
- package/src/markdown.ts +63 -4
- package/src/message-formatting.test.ts +60 -1
- package/src/message-formatting.ts +58 -15
- package/src/session-handler/thread-session-runtime.ts +49 -18
- package/src/store.ts +8 -0
|
@@ -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;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// Terminal send command for creating Discord threads and scheduling prompts.
|
|
2
|
+
// Designed to work in CI/headless environments with just KIMAKI_BOT_TOKEN.
|
|
3
|
+
// The local SQLite database (channel_directories) is NOT required for the basic
|
|
4
|
+
// flow: post message → create thread → remote bot picks it up. The local project
|
|
5
|
+
// directory mapping is only needed for --send-at, --wait, and --cwd.
|
|
2
6
|
import { goke } from 'goke';
|
|
3
7
|
import { z } from 'zod';
|
|
4
8
|
import { note } from '@clack/prompts';
|
|
@@ -271,9 +275,18 @@ cli
|
|
|
271
275
|
if (!threadData.parent_id) {
|
|
272
276
|
throw new Error(`Thread has no parent channel: ${targetThreadId}`);
|
|
273
277
|
}
|
|
278
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
279
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
280
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
281
|
+
// We only require it for features that genuinely need a local directory
|
|
282
|
+
// (scheduled tasks and --wait).
|
|
274
283
|
const channelConfig = await getChannelDirectory(threadData.parent_id);
|
|
275
|
-
if
|
|
276
|
-
|
|
284
|
+
// Guard early: fail before sending the message if a feature that
|
|
285
|
+
// needs local project directory mapping is requested.
|
|
286
|
+
if (!channelConfig && (parsedSchedule || options.wait)) {
|
|
287
|
+
const flag = parsedSchedule ? '--send-at' : '--wait';
|
|
288
|
+
throw new Error('Thread parent channel is not configured with a project directory. ' +
|
|
289
|
+
`${flag} requires a local project mapping. Run the bot first to sync channel data.`);
|
|
277
290
|
}
|
|
278
291
|
if (parsedSchedule) {
|
|
279
292
|
const payload = {
|
|
@@ -298,6 +311,7 @@ cli
|
|
|
298
311
|
channelId: threadData.parent_id,
|
|
299
312
|
threadId: targetThreadId,
|
|
300
313
|
sessionId: sessionId || undefined,
|
|
314
|
+
// channelConfig is guaranteed: early guard threw if missing with --send-at
|
|
301
315
|
projectDirectory: channelConfig.directory,
|
|
302
316
|
});
|
|
303
317
|
const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
|
|
@@ -337,6 +351,8 @@ cli
|
|
|
337
351
|
process.stdout.write(`Session: ${existingSessionId}\n`);
|
|
338
352
|
process.stdout.write(`${threadUrl}\n`);
|
|
339
353
|
if (options.wait) {
|
|
354
|
+
// channelConfig is guaranteed here: early guard above already
|
|
355
|
+
// threw if channelConfig is missing when --wait is used.
|
|
340
356
|
const { waitAndOutputSession } = await import('../wait-session.js');
|
|
341
357
|
await waitAndOutputSession({
|
|
342
358
|
threadId: targetThreadId,
|
|
@@ -352,17 +368,25 @@ cli
|
|
|
352
368
|
}
|
|
353
369
|
// Get channel info to extract directory from topic
|
|
354
370
|
const channelData = (await rest.get(Routes.channel(channelId)));
|
|
371
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
372
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
373
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
374
|
+
// We only require it for features that genuinely need a local directory
|
|
375
|
+
// (--send-at, --wait, --cwd).
|
|
355
376
|
const channelConfig = await getChannelDirectory(channelData.id);
|
|
356
|
-
if (!channelConfig && !notifyOnly) {
|
|
357
|
-
cliLogger.log('Channel not configured');
|
|
358
|
-
throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
|
|
359
|
-
}
|
|
360
377
|
const projectDirectory = channelConfig?.directory;
|
|
378
|
+
// Features that require a local project directory mapping
|
|
379
|
+
const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd);
|
|
380
|
+
if (!channelConfig && needsProjectDirectory) {
|
|
381
|
+
throw new Error(`Channel #${channelData.name} is not configured with a project directory. ` +
|
|
382
|
+
`${parsedSchedule ? '--send-at' : options.wait ? '--wait' : '--cwd'} requires a local project mapping. ` +
|
|
383
|
+
'Run the bot first to sync channel data.');
|
|
384
|
+
}
|
|
361
385
|
// Validate --cwd is inside the project or an existing git worktree.
|
|
362
386
|
let resolvedCwd;
|
|
363
387
|
if (options.cwd) {
|
|
364
|
-
// projectDirectory is guaranteed here:
|
|
365
|
-
//
|
|
388
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
389
|
+
// already threw if channelConfig is missing when --cwd is used.
|
|
366
390
|
const cwdResult = await resolveSessionWorkingDirectory({
|
|
367
391
|
projectDirectory: projectDirectory,
|
|
368
392
|
candidatePath: options.cwd,
|
|
@@ -510,8 +534,8 @@ cli
|
|
|
510
534
|
process.stdout.write(`Session: ${newSessionId}\n`);
|
|
511
535
|
process.stdout.write(`${threadUrl}\n`);
|
|
512
536
|
if (options.wait) {
|
|
513
|
-
// projectDirectory is guaranteed here:
|
|
514
|
-
//
|
|
537
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
538
|
+
// already threw if channelConfig is missing when --wait is used.
|
|
515
539
|
const { waitAndOutputSession } = await import('../wait-session.js');
|
|
516
540
|
await waitAndOutputSession({
|
|
517
541
|
threadId: threadData.id,
|
|
@@ -126,6 +126,7 @@ cli
|
|
|
126
126
|
cli
|
|
127
127
|
.command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
|
|
128
128
|
.option('--project <path>', 'Project directory (defaults to cwd)')
|
|
129
|
+
.option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
|
|
129
130
|
.action(async (sessionId, options) => {
|
|
130
131
|
try {
|
|
131
132
|
const projectDirectory = path.resolve(options.project || '.');
|
|
@@ -137,8 +138,9 @@ cli
|
|
|
137
138
|
process.exit(EXIT_NO_RESTART);
|
|
138
139
|
}
|
|
139
140
|
// Try current project first (fast path)
|
|
141
|
+
const compactTools = !options.verbose;
|
|
140
142
|
const markdown = new ShareMarkdown(getClient());
|
|
141
|
-
const result = await markdown.generate({ sessionID: sessionId });
|
|
143
|
+
const result = await markdown.generate({ sessionID: sessionId, compactTools });
|
|
142
144
|
if (!(result instanceof Error)) {
|
|
143
145
|
process.stdout.write(result);
|
|
144
146
|
process.exit(0);
|
|
@@ -171,6 +173,7 @@ cli
|
|
|
171
173
|
const otherMarkdown = new ShareMarkdown(otherClient());
|
|
172
174
|
const otherResult = await otherMarkdown.generate({
|
|
173
175
|
sessionID: sessionId,
|
|
176
|
+
compactTools,
|
|
174
177
|
});
|
|
175
178
|
if (!(otherResult instanceof Error)) {
|
|
176
179
|
process.stdout.write(otherResult);
|
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
|
});
|
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}`);
|
|
@@ -332,15 +333,13 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
332
333
|
return;
|
|
333
334
|
}
|
|
334
335
|
}
|
|
335
|
-
//
|
|
336
|
-
//
|
|
336
|
+
// Detect messages that start with a mention of another user (not the bot).
|
|
337
|
+
// In channels these are fully ignored. In threads they are added to the
|
|
338
|
+
// session context without triggering the AI (noReply), so the agent sees
|
|
339
|
+
// user-to-user conversation on the next real turn.
|
|
337
340
|
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (mentionedUserId !== discordClient.user?.id) {
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
341
|
+
const isLeadingMentionToOtherUser = leadingMentionMatch &&
|
|
342
|
+
leadingMentionMatch[1] !== discordClient.user?.id;
|
|
344
343
|
if (message.partial) {
|
|
345
344
|
discordLogger.log(`Fetching partial message ${message.id}`);
|
|
346
345
|
const fetched = await message.fetch()
|
|
@@ -354,6 +353,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
354
353
|
// When mention mode is enabled, users without Kimaki role can message
|
|
355
354
|
// without getting a permission error - we just silently ignore.
|
|
356
355
|
const channel = message.channel;
|
|
356
|
+
// In text channels, messages starting with a mention to another user
|
|
357
|
+
// are fully ignored before any permission or mention-mode checks.
|
|
358
|
+
// This prevents permission-error replies for user-to-user conversation.
|
|
359
|
+
if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
357
362
|
if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
|
|
358
363
|
const mentionModeEnabled = await getChannelMentionMode(channel.id);
|
|
359
364
|
if (mentionModeEnabled) {
|
|
@@ -435,6 +440,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
435
440
|
discordLogger.log(`Ignoring thread ${thread.id}: no existing session and bot not mentioned`);
|
|
436
441
|
return;
|
|
437
442
|
}
|
|
443
|
+
// Context-only messages (user-to-user replies) can't be stored without
|
|
444
|
+
// an existing session. Skip early to avoid creating a runtime or running
|
|
445
|
+
// preprocessing for nothing.
|
|
446
|
+
if (isLeadingMentionToOtherUser && !hasExistingSession) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
438
449
|
const parent = thread.parent;
|
|
439
450
|
let projectDirectory;
|
|
440
451
|
if (parent) {
|
|
@@ -509,7 +520,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
509
520
|
const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
|
|
510
521
|
? extractBtwSuffix(message.content || '')
|
|
511
522
|
: null;
|
|
512
|
-
if (btwResult?.forceBtw && projectDirectory) {
|
|
523
|
+
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
513
524
|
const result = await forkSessionToBtwThread({
|
|
514
525
|
sourceThread: thread,
|
|
515
526
|
projectDirectory,
|
|
@@ -559,7 +570,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
559
570
|
appId: currentAppId,
|
|
560
571
|
});
|
|
561
572
|
// Cancel interactive UI when a real user sends a message.
|
|
562
|
-
|
|
573
|
+
// Context-only messages (user-to-user replies) should not interrupt
|
|
574
|
+
// the active run or dismiss pending UI.
|
|
575
|
+
if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
|
|
563
576
|
cancelPendingActionButtons(thread.id);
|
|
564
577
|
cancelHtmlActionsForThread(thread.id);
|
|
565
578
|
const dismissedPermission = await cancelPendingPermission(thread.id);
|
|
@@ -594,6 +607,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
594
607
|
model: cliInjectedModel,
|
|
595
608
|
permissions: cliInjectedPermissions,
|
|
596
609
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
610
|
+
noReply: isLeadingMentionToOtherUser || undefined,
|
|
597
611
|
sessionStartSource: sessionStartSource
|
|
598
612
|
? {
|
|
599
613
|
scheduleKind: sessionStartSource.scheduleKind,
|
package/dist/markdown.js
CHANGED
|
@@ -26,7 +26,7 @@ export class ShareMarkdown {
|
|
|
26
26
|
* @returns Error or markdown string
|
|
27
27
|
*/
|
|
28
28
|
async generate(options) {
|
|
29
|
-
const { sessionID, includeSystemInfo, lastAssistantOnly } = options;
|
|
29
|
+
const { sessionID, includeSystemInfo, lastAssistantOnly, compactTools = true } = options;
|
|
30
30
|
// Get session info
|
|
31
31
|
const sessionResponse = await this.client.session.get({
|
|
32
32
|
sessionID,
|
|
@@ -75,13 +75,13 @@ export class ShareMarkdown {
|
|
|
75
75
|
lines.push('');
|
|
76
76
|
}
|
|
77
77
|
for (const message of messagesToRender) {
|
|
78
|
-
const messageLines = this.renderMessage(message.info, message.parts);
|
|
78
|
+
const messageLines = this.renderMessage(message.info, message.parts, { compactTools });
|
|
79
79
|
lines.push(...messageLines);
|
|
80
80
|
lines.push('');
|
|
81
81
|
}
|
|
82
82
|
return lines.join('\n');
|
|
83
83
|
}
|
|
84
|
-
renderMessage(message, parts) {
|
|
84
|
+
renderMessage(message, parts, opts) {
|
|
85
85
|
const lines = [];
|
|
86
86
|
if (message.role === 'user') {
|
|
87
87
|
lines.push('### 👤 User');
|
|
@@ -128,7 +128,9 @@ export class ShareMarkdown {
|
|
|
128
128
|
return true;
|
|
129
129
|
});
|
|
130
130
|
for (const part of filteredParts) {
|
|
131
|
-
const partLines =
|
|
131
|
+
const partLines = opts.compactTools
|
|
132
|
+
? this.renderPartCompact(part, message)
|
|
133
|
+
: this.renderPart(part, message);
|
|
132
134
|
lines.push(...partLines);
|
|
133
135
|
}
|
|
134
136
|
// Add completion time if available
|
|
@@ -214,6 +216,59 @@ export class ShareMarkdown {
|
|
|
214
216
|
}
|
|
215
217
|
return lines;
|
|
216
218
|
}
|
|
219
|
+
/** Compact rendering: tool calls become a single line with line count instead of full output. */
|
|
220
|
+
renderPartCompact(part, message) {
|
|
221
|
+
// Non-tool parts render the same as verbose
|
|
222
|
+
if (part.type !== 'tool') {
|
|
223
|
+
return this.renderPart(part, message);
|
|
224
|
+
}
|
|
225
|
+
const lines = [];
|
|
226
|
+
if (part.state.status === 'completed') {
|
|
227
|
+
const output = part.state.output || '';
|
|
228
|
+
const lineCount = output ? output.split('\n').length : 0;
|
|
229
|
+
const inputSummary = this.compactInputSummary(part.state.input);
|
|
230
|
+
const outputLabel = lineCount > 0 ? `(${lineCount} lines)` : '';
|
|
231
|
+
const parts = [inputSummary, outputLabel].filter(Boolean).join(' ');
|
|
232
|
+
lines.push(`> 🛠️ **${part.tool}**${parts ? ` ${parts}` : ''}`);
|
|
233
|
+
lines.push('');
|
|
234
|
+
}
|
|
235
|
+
else if (part.state.status === 'error') {
|
|
236
|
+
const errorText = (part.state.error || 'Unknown error').split('\n')[0].slice(0, 120);
|
|
237
|
+
lines.push(`> ❌ **${part.tool}** — ${errorText}`);
|
|
238
|
+
lines.push('');
|
|
239
|
+
}
|
|
240
|
+
return lines;
|
|
241
|
+
}
|
|
242
|
+
/** Build a compact key=value summary of tool input, max 2 params, 80 chars each. */
|
|
243
|
+
compactInputSummary(input) {
|
|
244
|
+
if (!input)
|
|
245
|
+
return '';
|
|
246
|
+
const entries = Object.entries(input);
|
|
247
|
+
if (entries.length === 0)
|
|
248
|
+
return '';
|
|
249
|
+
const parts = [];
|
|
250
|
+
const maxParams = 2;
|
|
251
|
+
for (let i = 0; i < Math.min(entries.length, maxParams); i++) {
|
|
252
|
+
const [key, value] = entries[i];
|
|
253
|
+
let val;
|
|
254
|
+
try {
|
|
255
|
+
val = typeof value === 'string'
|
|
256
|
+
? value
|
|
257
|
+
: (JSON.stringify(value) ?? String(value));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
val = String(value);
|
|
261
|
+
}
|
|
262
|
+
// Collapse whitespace for readability
|
|
263
|
+
const collapsed = val.replace(/\s+/g, ' ').trim();
|
|
264
|
+
const normalized = collapsed.length > 80 ? `${collapsed.slice(0, 80)}…` : collapsed;
|
|
265
|
+
parts.push(`${key}=${normalized}`);
|
|
266
|
+
}
|
|
267
|
+
if (entries.length > maxParams) {
|
|
268
|
+
parts.push('...');
|
|
269
|
+
}
|
|
270
|
+
return parts.join(', ');
|
|
271
|
+
}
|
|
217
272
|
formatDuration(ms) {
|
|
218
273
|
if (ms < 1000)
|
|
219
274
|
return `${ms}ms`;
|