kimaki 0.4.29 → 0.4.31
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/LICENSE +21 -0
- package/dist/cli.js +215 -37
- package/dist/commands/ask-question.js +49 -16
- package/dist/discord-bot.js +83 -1
- package/dist/discord-utils.js +4 -1
- package/dist/escape-backticks.test.js +11 -3
- package/dist/session-handler.js +18 -4
- package/dist/system-message.js +14 -6
- package/dist/unnest-code-blocks.js +110 -0
- package/dist/unnest-code-blocks.test.js +213 -0
- package/dist/utils.js +1 -0
- package/package.json +11 -12
- package/src/cli.ts +282 -46
- package/src/commands/ask-question.ts +57 -22
- package/src/discord-bot.ts +97 -1
- package/src/discord-utils.ts +4 -1
- package/src/escape-backticks.test.ts +11 -3
- package/src/session-handler.ts +20 -4
- package/src/system-message.ts +14 -6
- package/src/unnest-code-blocks.test.ts +225 -0
- package/src/unnest-code-blocks.ts +127 -0
- package/src/utils.ts +1 -0
package/src/cli.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { createLogger } from './logger.js'
|
|
|
46
46
|
import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
|
|
47
47
|
import http from 'node:http'
|
|
48
48
|
import { setDataDir, getDataDir, getLockPort } from './config.js'
|
|
49
|
+
import { extractTagsArrays } from './xml.js'
|
|
49
50
|
|
|
50
51
|
const cliLogger = createLogger('CLI')
|
|
51
52
|
const cli = cac('kimaki')
|
|
@@ -528,8 +529,14 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
528
529
|
}
|
|
529
530
|
|
|
530
531
|
const s = spinner()
|
|
531
|
-
s.start('Creating Discord client and connecting...')
|
|
532
532
|
|
|
533
|
+
// Start OpenCode server EARLY - let it initialize in parallel with Discord login.
|
|
534
|
+
// This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
|
|
535
|
+
const currentDir = process.cwd()
|
|
536
|
+
s.start('Starting OpenCode server...')
|
|
537
|
+
const opencodePromise = initializeOpencodeForDirectory(currentDir)
|
|
538
|
+
|
|
539
|
+
s.message('Connecting to Discord...')
|
|
533
540
|
const discordClient = await createDiscordClient()
|
|
534
541
|
|
|
535
542
|
const guilds: Guild[] = []
|
|
@@ -541,15 +548,56 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
541
548
|
discordClient.once(Events.ClientReady, async (c) => {
|
|
542
549
|
guilds.push(...Array.from(c.guilds.cache.values()))
|
|
543
550
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
(
|
|
548
|
-
|
|
549
|
-
|
|
551
|
+
// Process all guilds in parallel for faster startup
|
|
552
|
+
const guildResults = await Promise.all(
|
|
553
|
+
guilds.map(async (guild) => {
|
|
554
|
+
// Create Kimaki role if it doesn't exist, or fix its position (fire-and-forget)
|
|
555
|
+
guild.roles
|
|
556
|
+
.fetch()
|
|
557
|
+
.then(async (roles) => {
|
|
558
|
+
const existingRole = roles.find(
|
|
559
|
+
(role) => role.name.toLowerCase() === 'kimaki',
|
|
560
|
+
)
|
|
561
|
+
if (existingRole) {
|
|
562
|
+
// Move to bottom if not already there
|
|
563
|
+
if (existingRole.position > 1) {
|
|
564
|
+
await existingRole.setPosition(1)
|
|
565
|
+
cliLogger.info(`Moved "Kimaki" role to bottom in ${guild.name}`)
|
|
566
|
+
}
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
return guild.roles.create({
|
|
570
|
+
name: 'Kimaki',
|
|
571
|
+
position: 1, // Place at bottom so anyone with Manage Roles can assign it
|
|
572
|
+
reason:
|
|
573
|
+
'Kimaki bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
.then((role) => {
|
|
577
|
+
if (role) {
|
|
578
|
+
cliLogger.info(`Created "Kimaki" role in ${guild.name}`)
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
.catch((error) => {
|
|
582
|
+
cliLogger.warn(
|
|
583
|
+
`Could not create Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`,
|
|
584
|
+
)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
const channels = await getChannelsWithDescriptions(guild)
|
|
588
|
+
const kimakiChans = channels.filter(
|
|
589
|
+
(ch) =>
|
|
590
|
+
ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
return { guild, channels: kimakiChans }
|
|
594
|
+
}),
|
|
595
|
+
)
|
|
550
596
|
|
|
551
|
-
|
|
552
|
-
|
|
597
|
+
// Collect results
|
|
598
|
+
for (const result of guildResults) {
|
|
599
|
+
if (result.channels.length > 0) {
|
|
600
|
+
kimakiChannels.push(result)
|
|
553
601
|
}
|
|
554
602
|
}
|
|
555
603
|
|
|
@@ -612,32 +660,25 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
612
660
|
note(channelList, 'Existing Kimaki Channels')
|
|
613
661
|
}
|
|
614
662
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
s.stop('OpenCode server started!')
|
|
663
|
+
// Await the OpenCode server that was started in parallel with Discord login
|
|
664
|
+
s.start('Waiting for OpenCode server...')
|
|
665
|
+
const getClient = await opencodePromise
|
|
666
|
+
s.stop('OpenCode server ready!')
|
|
620
667
|
|
|
621
|
-
s.start('Fetching OpenCode
|
|
668
|
+
s.start('Fetching OpenCode data...')
|
|
622
669
|
|
|
623
|
-
|
|
670
|
+
// Fetch projects and commands in parallel
|
|
671
|
+
const [projects, allUserCommands] = await Promise.all([
|
|
672
|
+
getClient().project.list({}).then((r) => r.data || []).catch((error) => {
|
|
673
|
+
s.stop('Failed to fetch projects')
|
|
674
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
|
|
675
|
+
discordClient.destroy()
|
|
676
|
+
process.exit(EXIT_NO_RESTART)
|
|
677
|
+
}),
|
|
678
|
+
getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
|
|
679
|
+
])
|
|
624
680
|
|
|
625
|
-
|
|
626
|
-
const projectsResponse = await getClient().project.list({})
|
|
627
|
-
if (!projectsResponse.data) {
|
|
628
|
-
throw new Error('Failed to fetch projects')
|
|
629
|
-
}
|
|
630
|
-
projects = projectsResponse.data
|
|
631
|
-
s.stop(`Found ${projects.length} OpenCode project(s)`)
|
|
632
|
-
} catch (error) {
|
|
633
|
-
s.stop('Failed to fetch projects')
|
|
634
|
-
cliLogger.error(
|
|
635
|
-
'Error:',
|
|
636
|
-
error instanceof Error ? error.message : String(error),
|
|
637
|
-
)
|
|
638
|
-
discordClient.destroy()
|
|
639
|
-
process.exit(EXIT_NO_RESTART)
|
|
640
|
-
}
|
|
681
|
+
s.stop(`Found ${projects.length} OpenCode project(s)`)
|
|
641
682
|
|
|
642
683
|
const existingDirs = kimakiChannels.flatMap(({ channels }) =>
|
|
643
684
|
channels
|
|
@@ -745,19 +786,6 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
745
786
|
}
|
|
746
787
|
}
|
|
747
788
|
|
|
748
|
-
// Fetch user-defined commands using the already-running server
|
|
749
|
-
const allUserCommands: OpencodeCommand[] = []
|
|
750
|
-
try {
|
|
751
|
-
const commandsResponse = await getClient().command.list({
|
|
752
|
-
query: { directory: currentDir },
|
|
753
|
-
})
|
|
754
|
-
if (commandsResponse.data) {
|
|
755
|
-
allUserCommands.push(...commandsResponse.data)
|
|
756
|
-
}
|
|
757
|
-
} catch {
|
|
758
|
-
// Ignore errors fetching commands
|
|
759
|
-
}
|
|
760
|
-
|
|
761
789
|
// Log available user commands
|
|
762
790
|
const registrableCommands = allUserCommands.filter(
|
|
763
791
|
(cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
|
|
@@ -838,13 +866,31 @@ cli
|
|
|
838
866
|
'--data-dir <path>',
|
|
839
867
|
'Data directory for config and database (default: ~/.kimaki)',
|
|
840
868
|
)
|
|
841
|
-
.
|
|
869
|
+
.option('--install-url', 'Print the bot install URL and exit')
|
|
870
|
+
.action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string; installUrl?: boolean }) => {
|
|
842
871
|
try {
|
|
843
872
|
// Set data directory early, before any database access
|
|
844
873
|
if (options.dataDir) {
|
|
845
874
|
setDataDir(options.dataDir)
|
|
846
875
|
cliLogger.log(`Using data directory: ${getDataDir()}`)
|
|
847
876
|
}
|
|
877
|
+
|
|
878
|
+
if (options.installUrl) {
|
|
879
|
+
const db = getDatabase()
|
|
880
|
+
const existingBot = db
|
|
881
|
+
.prepare(
|
|
882
|
+
'SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
|
|
883
|
+
)
|
|
884
|
+
.get() as { app_id: string } | undefined
|
|
885
|
+
|
|
886
|
+
if (!existingBot) {
|
|
887
|
+
cliLogger.error('No bot configured yet. Run `kimaki` first to set up.')
|
|
888
|
+
process.exit(EXIT_NO_RESTART)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
console.log(generateBotInstallUrl({ clientId: existingBot.app_id }))
|
|
892
|
+
process.exit(0)
|
|
893
|
+
}
|
|
848
894
|
|
|
849
895
|
await checkSingleInstance()
|
|
850
896
|
await startLockServer()
|
|
@@ -958,6 +1004,196 @@ cli
|
|
|
958
1004
|
})
|
|
959
1005
|
|
|
960
1006
|
|
|
1007
|
+
// Magic prefix used to identify bot-initiated sessions.
|
|
1008
|
+
// The running bot will recognize this prefix and start a session.
|
|
1009
|
+
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
|
|
1010
|
+
|
|
1011
|
+
cli
|
|
1012
|
+
.command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
|
|
1013
|
+
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
1014
|
+
.option('-p, --prompt <prompt>', 'Initial prompt for the session')
|
|
1015
|
+
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
1016
|
+
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
1017
|
+
.action(async (options: { channel?: string; prompt?: string; name?: string; appId?: string }) => {
|
|
1018
|
+
try {
|
|
1019
|
+
const { channel: channelId, prompt, name, appId: optionAppId } = options
|
|
1020
|
+
|
|
1021
|
+
if (!channelId) {
|
|
1022
|
+
cliLogger.error('Channel ID is required. Use --channel <channelId>')
|
|
1023
|
+
process.exit(EXIT_NO_RESTART)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (!prompt) {
|
|
1027
|
+
cliLogger.error('Prompt is required. Use --prompt <prompt>')
|
|
1028
|
+
process.exit(EXIT_NO_RESTART)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Get bot token from env var or database
|
|
1032
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN
|
|
1033
|
+
let botToken: string | undefined
|
|
1034
|
+
let appId: string | undefined = optionAppId
|
|
1035
|
+
|
|
1036
|
+
if (envToken) {
|
|
1037
|
+
botToken = envToken
|
|
1038
|
+
if (!appId) {
|
|
1039
|
+
// Try to get app_id from database if available (optional in CI)
|
|
1040
|
+
try {
|
|
1041
|
+
const db = getDatabase()
|
|
1042
|
+
const botRow = db
|
|
1043
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1044
|
+
.get() as { app_id: string } | undefined
|
|
1045
|
+
appId = botRow?.app_id
|
|
1046
|
+
} catch {
|
|
1047
|
+
// Database might not exist in CI, that's ok
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
} else {
|
|
1051
|
+
// Fall back to database
|
|
1052
|
+
try {
|
|
1053
|
+
const db = getDatabase()
|
|
1054
|
+
const botRow = db
|
|
1055
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1056
|
+
.get() as { app_id: string; token: string } | undefined
|
|
1057
|
+
|
|
1058
|
+
if (botRow) {
|
|
1059
|
+
botToken = botRow.token
|
|
1060
|
+
appId = appId || botRow.app_id
|
|
1061
|
+
}
|
|
1062
|
+
} catch (e) {
|
|
1063
|
+
// Database error - will fall through to the check below
|
|
1064
|
+
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e))
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (!botToken) {
|
|
1069
|
+
cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.')
|
|
1070
|
+
process.exit(EXIT_NO_RESTART)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const s = spinner()
|
|
1074
|
+
s.start('Fetching channel info...')
|
|
1075
|
+
|
|
1076
|
+
// Get channel info to extract directory from topic
|
|
1077
|
+
const channelResponse = await fetch(
|
|
1078
|
+
`https://discord.com/api/v10/channels/${channelId}`,
|
|
1079
|
+
{
|
|
1080
|
+
headers: {
|
|
1081
|
+
'Authorization': `Bot ${botToken}`,
|
|
1082
|
+
},
|
|
1083
|
+
}
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
if (!channelResponse.ok) {
|
|
1087
|
+
const error = await channelResponse.text()
|
|
1088
|
+
s.stop('Failed to fetch channel')
|
|
1089
|
+
throw new Error(`Discord API error: ${channelResponse.status} - ${error}`)
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const channelData = await channelResponse.json() as {
|
|
1093
|
+
id: string
|
|
1094
|
+
name: string
|
|
1095
|
+
topic?: string
|
|
1096
|
+
guild_id: string
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (!channelData.topic) {
|
|
1100
|
+
s.stop('Channel has no topic')
|
|
1101
|
+
throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const extracted = extractTagsArrays({
|
|
1105
|
+
xml: channelData.topic,
|
|
1106
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
1110
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
1111
|
+
|
|
1112
|
+
if (!projectDirectory) {
|
|
1113
|
+
s.stop('No kimaki.directory tag found')
|
|
1114
|
+
throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Verify app ID matches if both are present
|
|
1118
|
+
if (channelAppId && appId && channelAppId !== appId) {
|
|
1119
|
+
s.stop('Channel belongs to different bot')
|
|
1120
|
+
throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
s.message('Creating starter message...')
|
|
1124
|
+
|
|
1125
|
+
// Create starter message with magic prefix
|
|
1126
|
+
// The full prompt goes in the message so the bot can read it
|
|
1127
|
+
const starterMessageResponse = await fetch(
|
|
1128
|
+
`https://discord.com/api/v10/channels/${channelId}/messages`,
|
|
1129
|
+
{
|
|
1130
|
+
method: 'POST',
|
|
1131
|
+
headers: {
|
|
1132
|
+
'Authorization': `Bot ${botToken}`,
|
|
1133
|
+
'Content-Type': 'application/json',
|
|
1134
|
+
},
|
|
1135
|
+
body: JSON.stringify({
|
|
1136
|
+
content: `${BOT_SESSION_PREFIX}\n${prompt}`,
|
|
1137
|
+
}),
|
|
1138
|
+
}
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
if (!starterMessageResponse.ok) {
|
|
1142
|
+
const error = await starterMessageResponse.text()
|
|
1143
|
+
s.stop('Failed to create message')
|
|
1144
|
+
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const starterMessage = await starterMessageResponse.json() as { id: string }
|
|
1148
|
+
|
|
1149
|
+
s.message('Creating thread...')
|
|
1150
|
+
|
|
1151
|
+
// Create thread from the message
|
|
1152
|
+
const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt)
|
|
1153
|
+
const threadResponse = await fetch(
|
|
1154
|
+
`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`,
|
|
1155
|
+
{
|
|
1156
|
+
method: 'POST',
|
|
1157
|
+
headers: {
|
|
1158
|
+
'Authorization': `Bot ${botToken}`,
|
|
1159
|
+
'Content-Type': 'application/json',
|
|
1160
|
+
},
|
|
1161
|
+
body: JSON.stringify({
|
|
1162
|
+
name: threadName.slice(0, 100),
|
|
1163
|
+
auto_archive_duration: 1440, // 1 day
|
|
1164
|
+
}),
|
|
1165
|
+
}
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
if (!threadResponse.ok) {
|
|
1169
|
+
const error = await threadResponse.text()
|
|
1170
|
+
s.stop('Failed to create thread')
|
|
1171
|
+
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const threadData = await threadResponse.json() as { id: string; name: string }
|
|
1175
|
+
|
|
1176
|
+
s.stop('Thread created!')
|
|
1177
|
+
|
|
1178
|
+
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
|
|
1179
|
+
|
|
1180
|
+
note(
|
|
1181
|
+
`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`,
|
|
1182
|
+
'✅ Thread Created',
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
console.log(threadUrl)
|
|
1186
|
+
|
|
1187
|
+
process.exit(0)
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
cliLogger.error(
|
|
1190
|
+
'Error:',
|
|
1191
|
+
error instanceof Error ? error.message : String(error),
|
|
1192
|
+
)
|
|
1193
|
+
process.exit(EXIT_NO_RESTART)
|
|
1194
|
+
}
|
|
1195
|
+
})
|
|
1196
|
+
|
|
961
1197
|
|
|
962
1198
|
cli.help()
|
|
963
1199
|
cli.parse()
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from 'discord.js'
|
|
11
11
|
import crypto from 'node:crypto'
|
|
12
12
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
13
|
-
import {
|
|
13
|
+
import { getOpencodeClientV2 } from '../opencode.js'
|
|
14
14
|
import { createLogger } from '../logger.js'
|
|
15
15
|
|
|
16
16
|
const logger = createLogger('ASK_QUESTION')
|
|
@@ -200,31 +200,20 @@ async function submitQuestionAnswers(
|
|
|
200
200
|
context: PendingQuestionContext
|
|
201
201
|
): Promise<void> {
|
|
202
202
|
try {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return context.answers[i] || []
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
// Reply to the question using direct HTTP call to OpenCode API
|
|
209
|
-
// (v1 SDK doesn't have question.reply, so we call it directly)
|
|
210
|
-
const port = getOpencodeServerPort(context.directory)
|
|
211
|
-
if (!port) {
|
|
203
|
+
const clientV2 = getOpencodeClientV2(context.directory)
|
|
204
|
+
if (!clientV2) {
|
|
212
205
|
throw new Error('OpenCode server not found for directory')
|
|
213
206
|
}
|
|
214
207
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
headers: { 'Content-Type': 'application/json' },
|
|
220
|
-
body: JSON.stringify({ answers: answersPayload }),
|
|
221
|
-
}
|
|
222
|
-
)
|
|
208
|
+
// Build answers array: each element is an array of selected labels for that question
|
|
209
|
+
const answers = context.questions.map((_, i) => {
|
|
210
|
+
return context.answers[i] || []
|
|
211
|
+
})
|
|
223
212
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
213
|
+
await clientV2.question.reply({
|
|
214
|
+
requestID: context.requestId,
|
|
215
|
+
answers,
|
|
216
|
+
})
|
|
228
217
|
|
|
229
218
|
logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`)
|
|
230
219
|
} catch (error) {
|
|
@@ -275,3 +264,49 @@ export function parseAskUserQuestionTool(part: {
|
|
|
275
264
|
|
|
276
265
|
return input
|
|
277
266
|
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Cancel a pending question for a thread (e.g., when user sends a new message).
|
|
270
|
+
* Sends cancellation response to OpenCode so the session can continue.
|
|
271
|
+
*/
|
|
272
|
+
export async function cancelPendingQuestion(threadId: string): Promise<boolean> {
|
|
273
|
+
// Find pending question for this thread
|
|
274
|
+
let contextHash: string | undefined
|
|
275
|
+
let context: PendingQuestionContext | undefined
|
|
276
|
+
for (const [hash, ctx] of pendingQuestionContexts) {
|
|
277
|
+
if (ctx.thread.id === threadId) {
|
|
278
|
+
contextHash = hash
|
|
279
|
+
context = ctx
|
|
280
|
+
break
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!contextHash || !context) {
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const clientV2 = getOpencodeClientV2(context.directory)
|
|
290
|
+
if (!clientV2) {
|
|
291
|
+
throw new Error('OpenCode server not found for directory')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Preserve already-answered questions, mark unanswered as cancelled
|
|
295
|
+
const answers = context.questions.map((_, i) => {
|
|
296
|
+
return context.answers[i] || ['(cancelled - user sent new message)']
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
await clientV2.question.reply({
|
|
300
|
+
requestID: context.requestId,
|
|
301
|
+
answers,
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
logger.log(`Cancelled question ${context.requestId} due to new user message`)
|
|
305
|
+
} catch (error) {
|
|
306
|
+
logger.error('Failed to cancel question:', error)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Clean up regardless of whether the API call succeeded
|
|
310
|
+
pendingQuestionContexts.delete(contextHash)
|
|
311
|
+
return true
|
|
312
|
+
}
|
package/src/discord-bot.ts
CHANGED
|
@@ -180,7 +180,10 @@ export async function startDiscordBot({
|
|
|
180
180
|
)
|
|
181
181
|
|
|
182
182
|
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
183
|
-
await message.
|
|
183
|
+
await message.reply({
|
|
184
|
+
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
185
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
186
|
+
})
|
|
184
187
|
return
|
|
185
188
|
}
|
|
186
189
|
}
|
|
@@ -412,6 +415,99 @@ export async function startDiscordBot({
|
|
|
412
415
|
}
|
|
413
416
|
})
|
|
414
417
|
|
|
418
|
+
// Magic prefix used by `kimaki start-session` CLI command to initiate sessions
|
|
419
|
+
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
|
|
420
|
+
|
|
421
|
+
// Handle bot-initiated threads created by `kimaki start-session`
|
|
422
|
+
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
423
|
+
try {
|
|
424
|
+
if (!newlyCreated) {
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Only handle threads in text channels
|
|
429
|
+
const parent = thread.parent as TextChannel | null
|
|
430
|
+
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Get the starter message to check for magic prefix
|
|
435
|
+
const starterMessage = await thread.fetchStarterMessage().catch(() => null)
|
|
436
|
+
if (!starterMessage) {
|
|
437
|
+
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Only handle messages from this bot with the magic prefix
|
|
442
|
+
if (starterMessage.author.id !== discordClient.user?.id) {
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
|
|
451
|
+
|
|
452
|
+
// Extract the prompt (everything after the prefix)
|
|
453
|
+
const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim()
|
|
454
|
+
if (!prompt) {
|
|
455
|
+
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Extract directory from parent channel topic
|
|
460
|
+
if (!parent.topic) {
|
|
461
|
+
discordLogger.log(`[BOT_SESSION] Parent channel has no topic`)
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const extracted = extractTagsArrays({
|
|
466
|
+
xml: parent.topic,
|
|
467
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
471
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
472
|
+
|
|
473
|
+
if (!projectDirectory) {
|
|
474
|
+
discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`)
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
479
|
+
discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
484
|
+
discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`)
|
|
485
|
+
await thread.send({
|
|
486
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
487
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
488
|
+
})
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`)
|
|
493
|
+
|
|
494
|
+
await handleOpencodeSession({
|
|
495
|
+
prompt,
|
|
496
|
+
thread,
|
|
497
|
+
projectDirectory,
|
|
498
|
+
channelId: parent.id,
|
|
499
|
+
})
|
|
500
|
+
} catch (error) {
|
|
501
|
+
voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', error)
|
|
502
|
+
try {
|
|
503
|
+
const errMsg = error instanceof Error ? error.message : String(error)
|
|
504
|
+
await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
|
|
505
|
+
} catch {
|
|
506
|
+
// Ignore send errors
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
})
|
|
510
|
+
|
|
415
511
|
await discordClient.login(token)
|
|
416
512
|
|
|
417
513
|
const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
|
package/src/discord-utils.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { Lexer } from 'marked'
|
|
12
12
|
import { extractTagsArrays } from './xml.js'
|
|
13
13
|
import { formatMarkdownTables } from './format-tables.js'
|
|
14
|
+
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
|
|
14
15
|
import { createLogger } from './logger.js'
|
|
15
16
|
|
|
16
17
|
const discordLogger = createLogger('DISCORD')
|
|
@@ -125,7 +126,8 @@ export function splitMarkdownForDiscord({
|
|
|
125
126
|
|
|
126
127
|
// calculate overhead for code block markers
|
|
127
128
|
const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0
|
|
128
|
-
|
|
129
|
+
// ensure at least 10 chars available, even if maxLength is very small
|
|
130
|
+
const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50)
|
|
129
131
|
|
|
130
132
|
const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock)
|
|
131
133
|
|
|
@@ -198,6 +200,7 @@ export async function sendThreadMessage(
|
|
|
198
200
|
const MAX_LENGTH = 2000
|
|
199
201
|
|
|
200
202
|
content = formatMarkdownTables(content)
|
|
203
|
+
content = unnestCodeBlocksFromLists(content)
|
|
201
204
|
content = escapeBackticksInCodeBlocks(content)
|
|
202
205
|
|
|
203
206
|
// If custom flags provided, send as single message (no chunking)
|
|
@@ -376,11 +376,19 @@ test('splitMarkdownForDiscord handles very long line inside code block', () => {
|
|
|
376
376
|
\`\`\`
|
|
377
377
|
",
|
|
378
378
|
"\`\`\`js
|
|
379
|
-
|
|
380
|
-
\`\`\`
|
|
379
|
+
veryverylo\`\`\`
|
|
381
380
|
",
|
|
382
381
|
"\`\`\`js
|
|
383
|
-
|
|
382
|
+
nglinethat\`\`\`
|
|
383
|
+
",
|
|
384
|
+
"\`\`\`js
|
|
385
|
+
exceedsmax\`\`\`
|
|
386
|
+
",
|
|
387
|
+
"\`\`\`js
|
|
388
|
+
length
|
|
389
|
+
\`\`\`
|
|
390
|
+
",
|
|
391
|
+
"short
|
|
384
392
|
\`\`\`
|
|
385
393
|
",
|
|
386
394
|
]
|