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
|
@@ -5,7 +5,7 @@ import { note } from '@clack/prompts'
|
|
|
5
5
|
import YAML from 'yaml'
|
|
6
6
|
import * as errore from 'errore'
|
|
7
7
|
import type { OpencodeClient, Event as OpenCodeEvent } from '@opencode-ai/sdk/v2'
|
|
8
|
-
import { Events, ActivityType, type PresenceStatusData, type Guild, Routes } from 'discord.js'
|
|
8
|
+
import { Events, ActivityType, type PresenceStatusData, type Guild, type Client, Routes } from 'discord.js'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import fs from 'node:fs'
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
@@ -98,74 +98,7 @@ cli
|
|
|
98
98
|
|
|
99
99
|
cliLogger.log('Finding guild...')
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
let guild: Guild
|
|
103
|
-
if (options.guild) {
|
|
104
|
-
const guildId = String(options.guild)
|
|
105
|
-
const foundGuild = client.guilds.cache.get(guildId)
|
|
106
|
-
if (!foundGuild) {
|
|
107
|
-
cliLogger.log('Guild not found')
|
|
108
|
-
cliLogger.error(`Guild not found: ${guildId}`)
|
|
109
|
-
void client.destroy()
|
|
110
|
-
process.exit(EXIT_NO_RESTART)
|
|
111
|
-
}
|
|
112
|
-
guild = foundGuild
|
|
113
|
-
} else {
|
|
114
|
-
const existingChannelId = await (await getDb()).query.channel_directories.findFirst({
|
|
115
|
-
where: { channel_type: 'text' },
|
|
116
|
-
orderBy: { created_at: 'desc' },
|
|
117
|
-
columns: { channel_id: true },
|
|
118
|
-
}).then((row) => row?.channel_id)
|
|
119
|
-
|
|
120
|
-
if (existingChannelId) {
|
|
121
|
-
try {
|
|
122
|
-
const ch = await client.channels.fetch(existingChannelId)
|
|
123
|
-
if (ch && !ch.isDMBased()) {
|
|
124
|
-
guild = ch.guild
|
|
125
|
-
} else {
|
|
126
|
-
throw new Error('Channel has no guild')
|
|
127
|
-
}
|
|
128
|
-
} catch (error) {
|
|
129
|
-
cliLogger.debug(
|
|
130
|
-
'Failed to fetch existing channel while selecting guild:',
|
|
131
|
-
error instanceof Error ? error.stack : String(error),
|
|
132
|
-
)
|
|
133
|
-
let firstGuild = client.guilds.cache.first()
|
|
134
|
-
if (!firstGuild) {
|
|
135
|
-
// Cache might be empty, try fetching guilds from API
|
|
136
|
-
const fetched = await client.guilds.fetch()
|
|
137
|
-
const firstOAuth2Guild = fetched.first()
|
|
138
|
-
if (firstOAuth2Guild) {
|
|
139
|
-
firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (!firstGuild) {
|
|
143
|
-
cliLogger.log('No guild found')
|
|
144
|
-
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
145
|
-
void client.destroy()
|
|
146
|
-
process.exit(EXIT_NO_RESTART)
|
|
147
|
-
}
|
|
148
|
-
guild = firstGuild
|
|
149
|
-
}
|
|
150
|
-
} else {
|
|
151
|
-
let firstGuild = client.guilds.cache.first()
|
|
152
|
-
if (!firstGuild) {
|
|
153
|
-
// Cache might be empty, try fetching guilds from API
|
|
154
|
-
const fetched = await client.guilds.fetch()
|
|
155
|
-
const firstOAuth2Guild = fetched.first()
|
|
156
|
-
if (firstOAuth2Guild) {
|
|
157
|
-
firstGuild = await client.guilds.fetch(firstOAuth2Guild.id)
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (!firstGuild) {
|
|
161
|
-
cliLogger.log('No guild found')
|
|
162
|
-
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
163
|
-
void client.destroy()
|
|
164
|
-
process.exit(EXIT_NO_RESTART)
|
|
165
|
-
}
|
|
166
|
-
guild = firstGuild
|
|
167
|
-
}
|
|
168
|
-
}
|
|
101
|
+
const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild })
|
|
169
102
|
|
|
170
103
|
// Check if channel already exists in this guild
|
|
171
104
|
cliLogger.log('Checking for existing channel...')
|
|
@@ -466,24 +399,7 @@ cli
|
|
|
466
399
|
client.login(botToken).catch(reject)
|
|
467
400
|
})
|
|
468
401
|
|
|
469
|
-
|
|
470
|
-
if (options.guild) {
|
|
471
|
-
const found = client.guilds.cache.get(options.guild)
|
|
472
|
-
if (!found) {
|
|
473
|
-
cliLogger.error(`Guild not found: ${options.guild}`)
|
|
474
|
-
void client.destroy()
|
|
475
|
-
process.exit(EXIT_NO_RESTART)
|
|
476
|
-
}
|
|
477
|
-
guild = found
|
|
478
|
-
} else {
|
|
479
|
-
const first = client.guilds.cache.first()
|
|
480
|
-
if (!first) {
|
|
481
|
-
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
482
|
-
void client.destroy()
|
|
483
|
-
process.exit(EXIT_NO_RESTART)
|
|
484
|
-
}
|
|
485
|
-
guild = first
|
|
486
|
-
}
|
|
402
|
+
const guild = await resolveGuildForProjectCommand({ client, guildIdOverride: options.guild })
|
|
487
403
|
|
|
488
404
|
const { textChannelId, channelName } = await createProjectChannels({
|
|
489
405
|
guild,
|
|
@@ -505,4 +421,100 @@ cli
|
|
|
505
421
|
})
|
|
506
422
|
|
|
507
423
|
|
|
424
|
+
// Resolve the guild for project add/create commands. In gateway mode the
|
|
425
|
+
// guild cache only contains authorized guilds, so picking from cache is safe.
|
|
426
|
+
// The old approach fetched an existing channel to infer the guild, but that
|
|
427
|
+
// breaks when the channel belongs to a different guild (e.g. old self-hosted
|
|
428
|
+
// bot channels) and the gateway proxy rejects the REST call. This led to a
|
|
429
|
+
// non-deterministic fallback that picked the wrong guild.
|
|
430
|
+
async function resolveGuildForProjectCommand({ client, guildIdOverride }: { client: Client; guildIdOverride?: string }): Promise<Guild> {
|
|
431
|
+
if (guildIdOverride) {
|
|
432
|
+
const found = client.guilds.cache.get(guildIdOverride)
|
|
433
|
+
if (!found) {
|
|
434
|
+
cliLogger.error(`Guild not found: ${guildIdOverride}`)
|
|
435
|
+
void client.destroy()
|
|
436
|
+
process.exit(EXIT_NO_RESTART)
|
|
437
|
+
}
|
|
438
|
+
return found
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Try existing channel lookup to find the guild the user already has channels in.
|
|
442
|
+
// This handles multi-guild setups where we want to add to the same guild.
|
|
443
|
+
const db = await getDb()
|
|
444
|
+
const existingChannels = await db.query.channel_directories.findMany({
|
|
445
|
+
where: { channel_type: 'text' },
|
|
446
|
+
orderBy: { created_at: 'desc' },
|
|
447
|
+
columns: { channel_id: true },
|
|
448
|
+
limit: 20,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
// Log available guilds for debugging guild selection issues
|
|
452
|
+
const cachedGuilds = Array.from(client.guilds.cache.values())
|
|
453
|
+
cliLogger.debug(`Guilds in cache (${cachedGuilds.length}): ${cachedGuilds.map((g) => `${g.name} (${g.id})`).join(', ')}`)
|
|
454
|
+
|
|
455
|
+
// When multiple guilds are available, find which guild has the most
|
|
456
|
+
// existing channels. The user's main guild will have far more channels
|
|
457
|
+
// than a test/demo guild.
|
|
458
|
+
const guildHits = new Map<string, { guild: Guild; count: number }>()
|
|
459
|
+
for (const row of existingChannels) {
|
|
460
|
+
try {
|
|
461
|
+
const ch = await client.channels.fetch(row.channel_id)
|
|
462
|
+
if (ch && !ch.isDMBased()) {
|
|
463
|
+
const entry = guildHits.get(ch.guild.id)
|
|
464
|
+
if (entry) {
|
|
465
|
+
entry.count++
|
|
466
|
+
} else {
|
|
467
|
+
guildHits.set(ch.guild.id, { guild: ch.guild, count: 1 })
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
// Channel might be in a different guild (gateway proxy rejects) or deleted, skip
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (guildHits.size > 0) {
|
|
476
|
+
// Pick the guild with the most channels
|
|
477
|
+
const best = Array.from(guildHits.values()).sort((a, b) => b.count - a.count)[0]!
|
|
478
|
+
cliLogger.debug(
|
|
479
|
+
`Guild channel counts: ${Array.from(guildHits.values()).map((e) => `${e.guild.name} (${e.guild.id}): ${e.count}`).join(', ')}`,
|
|
480
|
+
)
|
|
481
|
+
cliLogger.debug(`Selected guild: ${best.guild.name} (${best.guild.id}) with ${best.count} channels`)
|
|
482
|
+
return best.guild
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
cliLogger.debug('Could not resolve guild from existing channels, falling back to cache')
|
|
486
|
+
|
|
487
|
+
// If only one guild in cache, use it directly (common case).
|
|
488
|
+
// If multiple guilds, error out and ask the user to specify --guild
|
|
489
|
+
// since we can't determine which one to use.
|
|
490
|
+
if (cachedGuilds.length === 1) {
|
|
491
|
+
return cachedGuilds[0]!
|
|
492
|
+
}
|
|
493
|
+
if (cachedGuilds.length > 1) {
|
|
494
|
+
cliLogger.error(
|
|
495
|
+
`Multiple guilds found. Use --guild to specify which one:\n${cachedGuilds.map((g) => ` ${g.id} ${g.name}`).join('\n')}`,
|
|
496
|
+
)
|
|
497
|
+
void client.destroy()
|
|
498
|
+
process.exit(EXIT_NO_RESTART)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Cache empty, try fetching
|
|
502
|
+
const fetched = await client.guilds.fetch()
|
|
503
|
+
if (fetched.size === 1) {
|
|
504
|
+
const firstOAuth2Guild = fetched.first()!
|
|
505
|
+
return await client.guilds.fetch(firstOAuth2Guild.id)
|
|
506
|
+
}
|
|
507
|
+
if (fetched.size > 1) {
|
|
508
|
+
cliLogger.error(
|
|
509
|
+
`Multiple guilds found. Use --guild to specify which one:\n${Array.from(fetched.values()).map((g) => ` ${g.id} ${g.name}`).join('\n')}`,
|
|
510
|
+
)
|
|
511
|
+
void client.destroy()
|
|
512
|
+
process.exit(EXIT_NO_RESTART)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
cliLogger.error('No guild found. Add the bot to a server first.')
|
|
516
|
+
void client.destroy()
|
|
517
|
+
process.exit(EXIT_NO_RESTART)
|
|
518
|
+
}
|
|
519
|
+
|
|
508
520
|
export default cli
|
package/src/cli-commands/send.ts
CHANGED
|
@@ -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'
|
|
@@ -395,10 +399,20 @@ cli
|
|
|
395
399
|
throw new Error(`Thread has no parent channel: ${targetThreadId}`)
|
|
396
400
|
}
|
|
397
401
|
|
|
402
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
403
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
404
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
405
|
+
// We only require it for features that genuinely need a local directory
|
|
406
|
+
// (scheduled tasks and --wait).
|
|
398
407
|
const channelConfig = await getChannelDirectory(threadData.parent_id)
|
|
399
|
-
|
|
408
|
+
|
|
409
|
+
// Guard early: fail before sending the message if a feature that
|
|
410
|
+
// needs local project directory mapping is requested.
|
|
411
|
+
if (!channelConfig && (parsedSchedule || options.wait)) {
|
|
412
|
+
const flag = parsedSchedule ? '--send-at' : '--wait'
|
|
400
413
|
throw new Error(
|
|
401
|
-
'Thread parent channel is not configured with a project directory'
|
|
414
|
+
'Thread parent channel is not configured with a project directory. ' +
|
|
415
|
+
`${flag} requires a local project mapping. Run the bot first to sync channel data.`,
|
|
402
416
|
)
|
|
403
417
|
}
|
|
404
418
|
|
|
@@ -425,7 +439,8 @@ cli
|
|
|
425
439
|
channelId: threadData.parent_id,
|
|
426
440
|
threadId: targetThreadId,
|
|
427
441
|
sessionId: sessionId || undefined,
|
|
428
|
-
|
|
442
|
+
// channelConfig is guaranteed: early guard threw if missing with --send-at
|
|
443
|
+
projectDirectory: channelConfig!.directory,
|
|
429
444
|
})
|
|
430
445
|
|
|
431
446
|
const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`
|
|
@@ -475,10 +490,12 @@ cli
|
|
|
475
490
|
process.stdout.write(`${threadUrl}\n`)
|
|
476
491
|
|
|
477
492
|
if (options.wait) {
|
|
493
|
+
// channelConfig is guaranteed here: early guard above already
|
|
494
|
+
// threw if channelConfig is missing when --wait is used.
|
|
478
495
|
const { waitAndOutputSession } = await import('../wait-session.js')
|
|
479
496
|
await waitAndOutputSession({
|
|
480
497
|
threadId: targetThreadId,
|
|
481
|
-
projectDirectory: channelConfig
|
|
498
|
+
projectDirectory: channelConfig!.directory,
|
|
482
499
|
waitStartedAtMs,
|
|
483
500
|
})
|
|
484
501
|
}
|
|
@@ -500,22 +517,29 @@ cli
|
|
|
500
517
|
guild_id: string
|
|
501
518
|
}
|
|
502
519
|
|
|
520
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
521
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
522
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
523
|
+
// We only require it for features that genuinely need a local directory
|
|
524
|
+
// (--send-at, --wait, --cwd).
|
|
503
525
|
const channelConfig = await getChannelDirectory(channelData.id)
|
|
526
|
+
const projectDirectory = channelConfig?.directory
|
|
504
527
|
|
|
505
|
-
|
|
506
|
-
|
|
528
|
+
// Features that require a local project directory mapping
|
|
529
|
+
const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd)
|
|
530
|
+
if (!channelConfig && needsProjectDirectory) {
|
|
507
531
|
throw new Error(
|
|
508
|
-
`Channel #${channelData.name} is not configured with a project directory.
|
|
532
|
+
`Channel #${channelData.name} is not configured with a project directory. ` +
|
|
533
|
+
`${parsedSchedule ? '--send-at' : options.wait ? '--wait' : '--cwd'} requires a local project mapping. ` +
|
|
534
|
+
'Run the bot first to sync channel data.',
|
|
509
535
|
)
|
|
510
536
|
}
|
|
511
537
|
|
|
512
|
-
const projectDirectory = channelConfig?.directory
|
|
513
|
-
|
|
514
538
|
// Validate --cwd is inside the project or an existing git worktree.
|
|
515
539
|
let resolvedCwd: string | undefined
|
|
516
540
|
if (options.cwd) {
|
|
517
|
-
// projectDirectory is guaranteed here:
|
|
518
|
-
//
|
|
541
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
542
|
+
// already threw if channelConfig is missing when --cwd is used.
|
|
519
543
|
const cwdResult = await resolveSessionWorkingDirectory({
|
|
520
544
|
projectDirectory: projectDirectory!,
|
|
521
545
|
candidatePath: options.cwd,
|
|
@@ -690,8 +714,8 @@ cli
|
|
|
690
714
|
process.stdout.write(`${threadUrl}\n`)
|
|
691
715
|
|
|
692
716
|
if (options.wait) {
|
|
693
|
-
// projectDirectory is guaranteed here:
|
|
694
|
-
//
|
|
717
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
718
|
+
// already threw if channelConfig is missing when --wait is used.
|
|
695
719
|
const { waitAndOutputSession } = await import('../wait-session.js')
|
|
696
720
|
await waitAndOutputSession({
|
|
697
721
|
threadId: threadData.id,
|
|
@@ -188,7 +188,8 @@ cli
|
|
|
188
188
|
'Read a session conversation as markdown (pipe to file to grep)',
|
|
189
189
|
)
|
|
190
190
|
.option('--project <path>', 'Project directory (defaults to cwd)')
|
|
191
|
-
.
|
|
191
|
+
.option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
|
|
192
|
+
.action(async (sessionId: string, options: { project?: string; verbose?: boolean }) => {
|
|
192
193
|
try {
|
|
193
194
|
const projectDirectory = path.resolve(options.project || '.')
|
|
194
195
|
|
|
@@ -202,8 +203,9 @@ cli
|
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
// Try current project first (fast path)
|
|
206
|
+
const compactTools = !options.verbose
|
|
205
207
|
const markdown = new ShareMarkdown(getClient())
|
|
206
|
-
const result = await markdown.generate({ sessionID: sessionId })
|
|
208
|
+
const result = await markdown.generate({ sessionID: sessionId, compactTools })
|
|
207
209
|
if (!(result instanceof Error)) {
|
|
208
210
|
process.stdout.write(result)
|
|
209
211
|
process.exit(0)
|
|
@@ -236,6 +238,7 @@ cli
|
|
|
236
238
|
const otherMarkdown = new ShareMarkdown(otherClient())
|
|
237
239
|
const otherResult = await otherMarkdown.generate({
|
|
238
240
|
sessionID: sessionId,
|
|
241
|
+
compactTools,
|
|
239
242
|
})
|
|
240
243
|
if (!(otherResult instanceof Error)) {
|
|
241
244
|
process.stdout.write(otherResult)
|
package/src/commands/agent.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getThreadSession,
|
|
22
22
|
getSessionAgent,
|
|
23
23
|
getChannelAgent,
|
|
24
|
+
getChannelWorktreesEnabled,
|
|
24
25
|
} from '../database.js'
|
|
25
26
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
26
27
|
import {
|
|
@@ -32,6 +33,14 @@ import {
|
|
|
32
33
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js'
|
|
33
34
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
34
35
|
import { getCurrentModelInfo } from './model.js'
|
|
36
|
+
import { isGitRepositoryRoot } from '../worktrees.js'
|
|
37
|
+
import {
|
|
38
|
+
formatAutoWorktreeName,
|
|
39
|
+
createWorktreeInBackground,
|
|
40
|
+
worktreeCreatingMessage,
|
|
41
|
+
} from './new-worktree.js'
|
|
42
|
+
import { WORKTREE_PREFIX } from './merge-worktree.js'
|
|
43
|
+
import { store } from '../store.js'
|
|
35
44
|
|
|
36
45
|
const agentLogger = createLogger(LogPrefix.AGENT)
|
|
37
46
|
|
|
@@ -597,26 +606,78 @@ async function handleQuickAgentWithPrompt({
|
|
|
597
606
|
|
|
598
607
|
await command.deferReply()
|
|
599
608
|
|
|
609
|
+
// Check if worktrees should be enabled (CLI flag OR channel setting),
|
|
610
|
+
// mirroring the logic in discord-bot.ts message handler.
|
|
611
|
+
const wantsWorktrees =
|
|
612
|
+
store.getState().useWorktrees ||
|
|
613
|
+
(await getChannelWorktreesEnabled(channel.id))
|
|
614
|
+
const shouldUseWorktrees =
|
|
615
|
+
wantsWorktrees && (await isGitRepositoryRoot(projectDirectory))
|
|
616
|
+
|
|
617
|
+
if (wantsWorktrees && !shouldUseWorktrees) {
|
|
618
|
+
agentLogger.warn(
|
|
619
|
+
`[WORKTREE] Skipping automatic worktree for non-git project directory: ${projectDirectory}`,
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const baseThreadName = prompt.slice(0, 80)
|
|
624
|
+
const threadName = shouldUseWorktrees
|
|
625
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
626
|
+
: baseThreadName
|
|
627
|
+
|
|
600
628
|
const starterMessage = await channel.send({
|
|
601
629
|
content: `» **${command.user.displayName}** (${resolvedAgentName}): ${displayText}`,
|
|
602
630
|
flags: SILENT_MESSAGE_FLAGS,
|
|
603
631
|
})
|
|
604
632
|
|
|
605
633
|
const thread = await starterMessage.startThread({
|
|
606
|
-
name:
|
|
634
|
+
name: threadName.slice(0, 80),
|
|
607
635
|
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
608
636
|
reason: `${resolvedAgentName} agent prompt`,
|
|
609
637
|
})
|
|
610
638
|
|
|
611
639
|
await thread.members.add(command.user.id)
|
|
612
640
|
|
|
613
|
-
|
|
641
|
+
// Create worktree in background if enabled, same as discord-bot.ts
|
|
642
|
+
let worktreePromise: Promise<string | Error> | undefined
|
|
643
|
+
if (shouldUseWorktrees) {
|
|
644
|
+
const worktreeName = formatAutoWorktreeName(baseThreadName.slice(0, 50))
|
|
645
|
+
agentLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
|
|
646
|
+
|
|
647
|
+
const worktreeStatusMessage = await thread
|
|
648
|
+
.send({
|
|
649
|
+
content: worktreeCreatingMessage(worktreeName),
|
|
650
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
651
|
+
})
|
|
652
|
+
.catch(() => undefined)
|
|
653
|
+
|
|
654
|
+
worktreePromise = createWorktreeInBackground({
|
|
655
|
+
thread,
|
|
656
|
+
starterMessage: worktreeStatusMessage,
|
|
657
|
+
worktreeName,
|
|
658
|
+
projectDirectory,
|
|
659
|
+
rest: command.client.rest,
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const sessionDirectory = await (async () => {
|
|
664
|
+
if (!worktreePromise) return projectDirectory
|
|
665
|
+
const result = await worktreePromise
|
|
666
|
+
if (result instanceof Error) return projectDirectory
|
|
667
|
+
return result
|
|
668
|
+
})()
|
|
669
|
+
|
|
670
|
+
await command
|
|
671
|
+
.editReply(`Sent with **${resolvedAgentName}** agent in ${thread.toString()}`)
|
|
672
|
+
.catch(() => {
|
|
673
|
+
agentLogger.warn('[AGENT] Failed to edit quick-agent reply, continuing session')
|
|
674
|
+
})
|
|
614
675
|
|
|
615
676
|
const runtime = getOrCreateRuntime({
|
|
616
677
|
threadId: thread.id,
|
|
617
678
|
thread,
|
|
618
679
|
projectDirectory,
|
|
619
|
-
sdkDirectory:
|
|
680
|
+
sdkDirectory: sessionDirectory,
|
|
620
681
|
channelId: channel.id,
|
|
621
682
|
appId,
|
|
622
683
|
})
|
package/src/discord-bot.ts
CHANGED
|
@@ -297,6 +297,8 @@ export async function startDiscordBot({
|
|
|
297
297
|
discordClient = await createDiscordClient()
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
store.setState({ useWorktrees: Boolean(useWorktrees) })
|
|
301
|
+
|
|
300
302
|
let currentAppId: string | undefined = appId
|
|
301
303
|
|
|
302
304
|
const setupHandlers = async (c: Client<true>) => {
|
|
@@ -503,15 +505,14 @@ export async function startDiscordBot({
|
|
|
503
505
|
}
|
|
504
506
|
}
|
|
505
507
|
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
+
// Detect messages that start with a mention of another user (not the bot).
|
|
509
|
+
// In channels these are fully ignored. In threads they are added to the
|
|
510
|
+
// session context without triggering the AI (noReply), so the agent sees
|
|
511
|
+
// user-to-user conversation on the next real turn.
|
|
508
512
|
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/)
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
return
|
|
513
|
-
}
|
|
514
|
-
}
|
|
513
|
+
const isLeadingMentionToOtherUser =
|
|
514
|
+
leadingMentionMatch &&
|
|
515
|
+
leadingMentionMatch[1] !== discordClient.user?.id
|
|
515
516
|
|
|
516
517
|
if (message.partial) {
|
|
517
518
|
discordLogger.log(`Fetching partial message ${message.id}`)
|
|
@@ -530,6 +531,14 @@ export async function startDiscordBot({
|
|
|
530
531
|
// When mention mode is enabled, users without Kimaki role can message
|
|
531
532
|
// without getting a permission error - we just silently ignore.
|
|
532
533
|
const channel = message.channel
|
|
534
|
+
|
|
535
|
+
// In text channels, messages starting with a mention to another user
|
|
536
|
+
// are fully ignored before any permission or mention-mode checks.
|
|
537
|
+
// This prevents permission-error replies for user-to-user conversation.
|
|
538
|
+
if (channel.type === ChannelType.GuildText && isLeadingMentionToOtherUser) {
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
|
|
533
542
|
if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
|
|
534
543
|
const mentionModeEnabled = await getChannelMentionMode(channel.id)
|
|
535
544
|
if (mentionModeEnabled) {
|
|
@@ -629,6 +638,13 @@ export async function startDiscordBot({
|
|
|
629
638
|
return
|
|
630
639
|
}
|
|
631
640
|
|
|
641
|
+
// Context-only messages (user-to-user replies) can't be stored without
|
|
642
|
+
// an existing session. Skip early to avoid creating a runtime or running
|
|
643
|
+
// preprocessing for nothing.
|
|
644
|
+
if (isLeadingMentionToOtherUser && !hasExistingSession) {
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
|
|
632
648
|
const parent = thread.parent as TextChannel | null
|
|
633
649
|
let projectDirectory: string | undefined
|
|
634
650
|
if (parent) {
|
|
@@ -713,7 +729,7 @@ export async function startDiscordBot({
|
|
|
713
729
|
projectDirectory && worktreeInfo?.status !== 'pending'
|
|
714
730
|
? extractBtwSuffix(message.content || '')
|
|
715
731
|
: null
|
|
716
|
-
if (btwResult?.forceBtw && projectDirectory) {
|
|
732
|
+
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
717
733
|
const result = await forkSessionToBtwThread({
|
|
718
734
|
sourceThread: thread,
|
|
719
735
|
projectDirectory,
|
|
@@ -775,7 +791,9 @@ export async function startDiscordBot({
|
|
|
775
791
|
})
|
|
776
792
|
|
|
777
793
|
// Cancel interactive UI when a real user sends a message.
|
|
778
|
-
|
|
794
|
+
// Context-only messages (user-to-user replies) should not interrupt
|
|
795
|
+
// the active run or dismiss pending UI.
|
|
796
|
+
if (!message.author.bot && !isCliInjectedPrompt && !isLeadingMentionToOtherUser) {
|
|
779
797
|
cancelPendingActionButtons(thread.id)
|
|
780
798
|
cancelHtmlActionsForThread(thread.id)
|
|
781
799
|
const dismissedPermission = await cancelPendingPermission(thread.id)
|
|
@@ -812,6 +830,7 @@ export async function startDiscordBot({
|
|
|
812
830
|
model: cliInjectedModel,
|
|
813
831
|
permissions: cliInjectedPermissions,
|
|
814
832
|
injectionGuardPatterns: cliInjectedInjectionGuardPatterns,
|
|
833
|
+
noReply: isLeadingMentionToOtherUser || undefined,
|
|
815
834
|
sessionStartSource: sessionStartSource
|
|
816
835
|
? {
|
|
817
836
|
scheduleKind: sessionStartSource.scheduleKind,
|
package/src/markdown.test.ts
CHANGED
|
@@ -45,6 +45,19 @@ function createMatchers(): DeterministicMatcher[] {
|
|
|
45
45
|
},
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const toolCallMatcher: DeterministicMatcher = {
|
|
49
|
+
id: 'tool-call-reply',
|
|
50
|
+
priority: 90,
|
|
51
|
+
when: { latestUserTextIncludes: 'use a tool please' },
|
|
52
|
+
then: {
|
|
53
|
+
parts: [
|
|
54
|
+
{ type: 'stream-start', warnings: [] },
|
|
55
|
+
{ type: 'tool-call', toolCallId: 'tc1', toolName: 'bash', input: JSON.stringify({ command: 'echo hello world', description: 'Print greeting' }) },
|
|
56
|
+
{ type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
const defaultMatcher: DeterministicMatcher = {
|
|
49
62
|
id: 'default-reply',
|
|
50
63
|
priority: 1,
|
|
@@ -59,13 +72,14 @@ function createMatchers(): DeterministicMatcher[] {
|
|
|
59
72
|
},
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
return [helloMatcher, defaultMatcher]
|
|
75
|
+
return [helloMatcher, toolCallMatcher, defaultMatcher]
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
let client: OpencodeClient
|
|
66
79
|
let directories: ReturnType<typeof createRunDirectories>
|
|
67
80
|
let testStartTime: number
|
|
68
81
|
let sessionID: string
|
|
82
|
+
let toolSessionID: string
|
|
69
83
|
|
|
70
84
|
beforeAll(async () => {
|
|
71
85
|
testStartTime = Date.now()
|
|
@@ -148,7 +162,39 @@ beforeAll(async () => {
|
|
|
148
162
|
setTimeout(resolve, 200)
|
|
149
163
|
})
|
|
150
164
|
}
|
|
151
|
-
|
|
165
|
+
|
|
166
|
+
// Create a second session that triggers a tool call (bash echo)
|
|
167
|
+
const toolCreateResult = await client.session.create({
|
|
168
|
+
directory: directories.projectDirectory,
|
|
169
|
+
title: 'Tool Call Session',
|
|
170
|
+
})
|
|
171
|
+
toolSessionID = toolCreateResult.data!.id
|
|
172
|
+
|
|
173
|
+
await client.session.promptAsync({
|
|
174
|
+
sessionID: toolSessionID,
|
|
175
|
+
directory: directories.projectDirectory,
|
|
176
|
+
parts: [{ type: 'text', text: 'use a tool please' }],
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Wait for tool execution to complete
|
|
180
|
+
const toolMaxWait = 15_000
|
|
181
|
+
const toolPollStart = Date.now()
|
|
182
|
+
while (Date.now() - toolPollStart < toolMaxWait) {
|
|
183
|
+
const msgs = await client.session.messages({
|
|
184
|
+
sessionID: toolSessionID,
|
|
185
|
+
directory: directories.projectDirectory,
|
|
186
|
+
})
|
|
187
|
+
const messages = msgs.data || []
|
|
188
|
+
const hasToolPart = messages.some((m) =>
|
|
189
|
+
m.parts.some((p) => p.type === 'tool' && p.state?.status === 'completed'),
|
|
190
|
+
)
|
|
191
|
+
if (hasToolPart) {
|
|
192
|
+
await new Promise((resolve) => { setTimeout(resolve, 500) })
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
await new Promise((resolve) => { setTimeout(resolve, 200) })
|
|
196
|
+
}
|
|
197
|
+
}, 30_000)
|
|
152
198
|
|
|
153
199
|
afterAll(async () => {
|
|
154
200
|
if (directories) {
|
|
@@ -313,3 +359,44 @@ test('generate markdown with lastAssistantOnly', async () => {
|
|
|
313
359
|
// Should contain the assistant response
|
|
314
360
|
expect(markdown).toContain('Hello! This is a deterministic markdown test response.')
|
|
315
361
|
})
|
|
362
|
+
|
|
363
|
+
test('compact tools: tool calls show one-liner with line count', async () => {
|
|
364
|
+
const exporter = new ShareMarkdown(client)
|
|
365
|
+
|
|
366
|
+
const result = await exporter.generate({
|
|
367
|
+
sessionID: toolSessionID,
|
|
368
|
+
compactTools: true,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
expect(errore.isOk(result)).toBe(true)
|
|
372
|
+
const md = errore.unwrap(result)
|
|
373
|
+
|
|
374
|
+
// Compact mode: exact one-liner format with params and line count
|
|
375
|
+
expect(md).toContain(
|
|
376
|
+
'> 🛠️ **bash** command=echo hello world, description=Print greeting',
|
|
377
|
+
)
|
|
378
|
+
expect(md).toMatch(/\(\d+ lines?\)/)
|
|
379
|
+
// Should NOT contain full output code blocks or input YAML
|
|
380
|
+
expect(md).not.toContain('**Output:**')
|
|
381
|
+
expect(md).not.toContain('**Input:**')
|
|
382
|
+
expect(md).not.toContain('```yaml')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('verbose tools: tool calls show full input and output', async () => {
|
|
386
|
+
const exporter = new ShareMarkdown(client)
|
|
387
|
+
|
|
388
|
+
const result = await exporter.generate({
|
|
389
|
+
sessionID: toolSessionID,
|
|
390
|
+
compactTools: false,
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
expect(errore.isOk(result)).toBe(true)
|
|
394
|
+
const md = errore.unwrap(result)
|
|
395
|
+
|
|
396
|
+
// Verbose mode: full tool rendering with input YAML and output code block
|
|
397
|
+
expect(md).toContain('#### 🛠️ Tool: bash')
|
|
398
|
+
expect(md).toContain('**Input:**')
|
|
399
|
+
expect(md).toContain('```yaml')
|
|
400
|
+
expect(md).toContain('**Output:**')
|
|
401
|
+
expect(md).toContain('hello world')
|
|
402
|
+
})
|