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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Kimaki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { createLogger } from './logger.js';
|
|
|
13
13
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
14
14
|
import http from 'node:http';
|
|
15
15
|
import { setDataDir, getDataDir, getLockPort } from './config.js';
|
|
16
|
+
import { extractTagsArrays } from './xml.js';
|
|
16
17
|
const cliLogger = createLogger('CLI');
|
|
17
18
|
const cli = cac('kimaki');
|
|
18
19
|
process.title = 'kimaki';
|
|
@@ -387,7 +388,12 @@ async function run({ restart, addChannels }) {
|
|
|
387
388
|
}
|
|
388
389
|
}
|
|
389
390
|
const s = spinner();
|
|
390
|
-
|
|
391
|
+
// Start OpenCode server EARLY - let it initialize in parallel with Discord login.
|
|
392
|
+
// This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
|
|
393
|
+
const currentDir = process.cwd();
|
|
394
|
+
s.start('Starting OpenCode server...');
|
|
395
|
+
const opencodePromise = initializeOpencodeForDirectory(currentDir);
|
|
396
|
+
s.message('Connecting to Discord...');
|
|
391
397
|
const discordClient = await createDiscordClient();
|
|
392
398
|
const guilds = [];
|
|
393
399
|
const kimakiChannels = [];
|
|
@@ -396,11 +402,43 @@ async function run({ restart, addChannels }) {
|
|
|
396
402
|
await new Promise((resolve, reject) => {
|
|
397
403
|
discordClient.once(Events.ClientReady, async (c) => {
|
|
398
404
|
guilds.push(...Array.from(c.guilds.cache.values()));
|
|
399
|
-
|
|
405
|
+
// Process all guilds in parallel for faster startup
|
|
406
|
+
const guildResults = await Promise.all(guilds.map(async (guild) => {
|
|
407
|
+
// Create Kimaki role if it doesn't exist, or fix its position (fire-and-forget)
|
|
408
|
+
guild.roles
|
|
409
|
+
.fetch()
|
|
410
|
+
.then(async (roles) => {
|
|
411
|
+
const existingRole = roles.find((role) => role.name.toLowerCase() === 'kimaki');
|
|
412
|
+
if (existingRole) {
|
|
413
|
+
// Move to bottom if not already there
|
|
414
|
+
if (existingRole.position > 1) {
|
|
415
|
+
await existingRole.setPosition(1);
|
|
416
|
+
cliLogger.info(`Moved "Kimaki" role to bottom in ${guild.name}`);
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
return guild.roles.create({
|
|
421
|
+
name: 'Kimaki',
|
|
422
|
+
position: 1, // Place at bottom so anyone with Manage Roles can assign it
|
|
423
|
+
reason: 'Kimaki bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
|
|
424
|
+
});
|
|
425
|
+
})
|
|
426
|
+
.then((role) => {
|
|
427
|
+
if (role) {
|
|
428
|
+
cliLogger.info(`Created "Kimaki" role in ${guild.name}`);
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
.catch((error) => {
|
|
432
|
+
cliLogger.warn(`Could not create Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
433
|
+
});
|
|
400
434
|
const channels = await getChannelsWithDescriptions(guild);
|
|
401
435
|
const kimakiChans = channels.filter((ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId));
|
|
402
|
-
|
|
403
|
-
|
|
436
|
+
return { guild, channels: kimakiChans };
|
|
437
|
+
}));
|
|
438
|
+
// Collect results
|
|
439
|
+
for (const result of guildResults) {
|
|
440
|
+
if (result.channels.length > 0) {
|
|
441
|
+
kimakiChannels.push(result);
|
|
404
442
|
}
|
|
405
443
|
}
|
|
406
444
|
resolve(null);
|
|
@@ -440,26 +478,22 @@ async function run({ restart, addChannels }) {
|
|
|
440
478
|
.join('\n');
|
|
441
479
|
note(channelList, 'Existing Kimaki Channels');
|
|
442
480
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
s.stop('OpenCode server
|
|
447
|
-
s.start('Fetching OpenCode
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
460
|
-
discordClient.destroy();
|
|
461
|
-
process.exit(EXIT_NO_RESTART);
|
|
462
|
-
}
|
|
481
|
+
// Await the OpenCode server that was started in parallel with Discord login
|
|
482
|
+
s.start('Waiting for OpenCode server...');
|
|
483
|
+
const getClient = await opencodePromise;
|
|
484
|
+
s.stop('OpenCode server ready!');
|
|
485
|
+
s.start('Fetching OpenCode data...');
|
|
486
|
+
// Fetch projects and commands in parallel
|
|
487
|
+
const [projects, allUserCommands] = await Promise.all([
|
|
488
|
+
getClient().project.list({}).then((r) => r.data || []).catch((error) => {
|
|
489
|
+
s.stop('Failed to fetch projects');
|
|
490
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
491
|
+
discordClient.destroy();
|
|
492
|
+
process.exit(EXIT_NO_RESTART);
|
|
493
|
+
}),
|
|
494
|
+
getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
|
|
495
|
+
]);
|
|
496
|
+
s.stop(`Found ${projects.length} OpenCode project(s)`);
|
|
463
497
|
const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
|
|
464
498
|
.filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
|
|
465
499
|
.map((ch) => ch.kimakiDirectory)
|
|
@@ -540,19 +574,6 @@ async function run({ restart, addChannels }) {
|
|
|
540
574
|
}
|
|
541
575
|
}
|
|
542
576
|
}
|
|
543
|
-
// Fetch user-defined commands using the already-running server
|
|
544
|
-
const allUserCommands = [];
|
|
545
|
-
try {
|
|
546
|
-
const commandsResponse = await getClient().command.list({
|
|
547
|
-
query: { directory: currentDir },
|
|
548
|
-
});
|
|
549
|
-
if (commandsResponse.data) {
|
|
550
|
-
allUserCommands.push(...commandsResponse.data);
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
catch {
|
|
554
|
-
// Ignore errors fetching commands
|
|
555
|
-
}
|
|
556
577
|
// Log available user commands
|
|
557
578
|
const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
|
|
558
579
|
if (registrableCommands.length > 0) {
|
|
@@ -597,6 +618,7 @@ cli
|
|
|
597
618
|
.option('--restart', 'Prompt for new credentials even if saved')
|
|
598
619
|
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
599
620
|
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
621
|
+
.option('--install-url', 'Print the bot install URL and exit')
|
|
600
622
|
.action(async (options) => {
|
|
601
623
|
try {
|
|
602
624
|
// Set data directory early, before any database access
|
|
@@ -604,6 +626,18 @@ cli
|
|
|
604
626
|
setDataDir(options.dataDir);
|
|
605
627
|
cliLogger.log(`Using data directory: ${getDataDir()}`);
|
|
606
628
|
}
|
|
629
|
+
if (options.installUrl) {
|
|
630
|
+
const db = getDatabase();
|
|
631
|
+
const existingBot = db
|
|
632
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
633
|
+
.get();
|
|
634
|
+
if (!existingBot) {
|
|
635
|
+
cliLogger.error('No bot configured yet. Run `kimaki` first to set up.');
|
|
636
|
+
process.exit(EXIT_NO_RESTART);
|
|
637
|
+
}
|
|
638
|
+
console.log(generateBotInstallUrl({ clientId: existingBot.app_id }));
|
|
639
|
+
process.exit(0);
|
|
640
|
+
}
|
|
607
641
|
await checkSingleInstance();
|
|
608
642
|
await startLockServer();
|
|
609
643
|
await run({
|
|
@@ -683,5 +717,149 @@ cli
|
|
|
683
717
|
process.exit(EXIT_NO_RESTART);
|
|
684
718
|
}
|
|
685
719
|
});
|
|
720
|
+
// Magic prefix used to identify bot-initiated sessions.
|
|
721
|
+
// The running bot will recognize this prefix and start a session.
|
|
722
|
+
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
|
|
723
|
+
cli
|
|
724
|
+
.command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
|
|
725
|
+
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
726
|
+
.option('-p, --prompt <prompt>', 'Initial prompt for the session')
|
|
727
|
+
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
728
|
+
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
729
|
+
.action(async (options) => {
|
|
730
|
+
try {
|
|
731
|
+
const { channel: channelId, prompt, name, appId: optionAppId } = options;
|
|
732
|
+
if (!channelId) {
|
|
733
|
+
cliLogger.error('Channel ID is required. Use --channel <channelId>');
|
|
734
|
+
process.exit(EXIT_NO_RESTART);
|
|
735
|
+
}
|
|
736
|
+
if (!prompt) {
|
|
737
|
+
cliLogger.error('Prompt is required. Use --prompt <prompt>');
|
|
738
|
+
process.exit(EXIT_NO_RESTART);
|
|
739
|
+
}
|
|
740
|
+
// Get bot token from env var or database
|
|
741
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN;
|
|
742
|
+
let botToken;
|
|
743
|
+
let appId = optionAppId;
|
|
744
|
+
if (envToken) {
|
|
745
|
+
botToken = envToken;
|
|
746
|
+
if (!appId) {
|
|
747
|
+
// Try to get app_id from database if available (optional in CI)
|
|
748
|
+
try {
|
|
749
|
+
const db = getDatabase();
|
|
750
|
+
const botRow = db
|
|
751
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
752
|
+
.get();
|
|
753
|
+
appId = botRow?.app_id;
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
// Database might not exist in CI, that's ok
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
// Fall back to database
|
|
762
|
+
try {
|
|
763
|
+
const db = getDatabase();
|
|
764
|
+
const botRow = db
|
|
765
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
766
|
+
.get();
|
|
767
|
+
if (botRow) {
|
|
768
|
+
botToken = botRow.token;
|
|
769
|
+
appId = appId || botRow.app_id;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
catch (e) {
|
|
773
|
+
// Database error - will fall through to the check below
|
|
774
|
+
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (!botToken) {
|
|
778
|
+
cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
|
|
779
|
+
process.exit(EXIT_NO_RESTART);
|
|
780
|
+
}
|
|
781
|
+
const s = spinner();
|
|
782
|
+
s.start('Fetching channel info...');
|
|
783
|
+
// Get channel info to extract directory from topic
|
|
784
|
+
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
|
785
|
+
headers: {
|
|
786
|
+
'Authorization': `Bot ${botToken}`,
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
if (!channelResponse.ok) {
|
|
790
|
+
const error = await channelResponse.text();
|
|
791
|
+
s.stop('Failed to fetch channel');
|
|
792
|
+
throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
|
|
793
|
+
}
|
|
794
|
+
const channelData = await channelResponse.json();
|
|
795
|
+
if (!channelData.topic) {
|
|
796
|
+
s.stop('Channel has no topic');
|
|
797
|
+
throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`);
|
|
798
|
+
}
|
|
799
|
+
const extracted = extractTagsArrays({
|
|
800
|
+
xml: channelData.topic,
|
|
801
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
802
|
+
});
|
|
803
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
804
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
805
|
+
if (!projectDirectory) {
|
|
806
|
+
s.stop('No kimaki.directory tag found');
|
|
807
|
+
throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`);
|
|
808
|
+
}
|
|
809
|
+
// Verify app ID matches if both are present
|
|
810
|
+
if (channelAppId && appId && channelAppId !== appId) {
|
|
811
|
+
s.stop('Channel belongs to different bot');
|
|
812
|
+
throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
|
|
813
|
+
}
|
|
814
|
+
s.message('Creating starter message...');
|
|
815
|
+
// Create starter message with magic prefix
|
|
816
|
+
// The full prompt goes in the message so the bot can read it
|
|
817
|
+
const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
818
|
+
method: 'POST',
|
|
819
|
+
headers: {
|
|
820
|
+
'Authorization': `Bot ${botToken}`,
|
|
821
|
+
'Content-Type': 'application/json',
|
|
822
|
+
},
|
|
823
|
+
body: JSON.stringify({
|
|
824
|
+
content: `${BOT_SESSION_PREFIX}\n${prompt}`,
|
|
825
|
+
}),
|
|
826
|
+
});
|
|
827
|
+
if (!starterMessageResponse.ok) {
|
|
828
|
+
const error = await starterMessageResponse.text();
|
|
829
|
+
s.stop('Failed to create message');
|
|
830
|
+
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
|
|
831
|
+
}
|
|
832
|
+
const starterMessage = await starterMessageResponse.json();
|
|
833
|
+
s.message('Creating thread...');
|
|
834
|
+
// Create thread from the message
|
|
835
|
+
const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
|
|
836
|
+
const threadResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`, {
|
|
837
|
+
method: 'POST',
|
|
838
|
+
headers: {
|
|
839
|
+
'Authorization': `Bot ${botToken}`,
|
|
840
|
+
'Content-Type': 'application/json',
|
|
841
|
+
},
|
|
842
|
+
body: JSON.stringify({
|
|
843
|
+
name: threadName.slice(0, 100),
|
|
844
|
+
auto_archive_duration: 1440, // 1 day
|
|
845
|
+
}),
|
|
846
|
+
});
|
|
847
|
+
if (!threadResponse.ok) {
|
|
848
|
+
const error = await threadResponse.text();
|
|
849
|
+
s.stop('Failed to create thread');
|
|
850
|
+
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
|
|
851
|
+
}
|
|
852
|
+
const threadData = await threadResponse.json();
|
|
853
|
+
s.stop('Thread created!');
|
|
854
|
+
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
855
|
+
note(`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`, '✅ Thread Created');
|
|
856
|
+
console.log(threadUrl);
|
|
857
|
+
process.exit(0);
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
861
|
+
process.exit(EXIT_NO_RESTART);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
686
864
|
cli.help();
|
|
687
865
|
cli.parse();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
7
|
-
import {
|
|
7
|
+
import { getOpencodeClientV2 } from '../opencode.js';
|
|
8
8
|
import { createLogger } from '../logger.js';
|
|
9
9
|
const logger = createLogger('ASK_QUESTION');
|
|
10
10
|
// Store pending question contexts by hash
|
|
@@ -128,25 +128,18 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
128
128
|
*/
|
|
129
129
|
async function submitQuestionAnswers(context) {
|
|
130
130
|
try {
|
|
131
|
+
const clientV2 = getOpencodeClientV2(context.directory);
|
|
132
|
+
if (!clientV2) {
|
|
133
|
+
throw new Error('OpenCode server not found for directory');
|
|
134
|
+
}
|
|
131
135
|
// Build answers array: each element is an array of selected labels for that question
|
|
132
|
-
const
|
|
136
|
+
const answers = context.questions.map((_, i) => {
|
|
133
137
|
return context.answers[i] || [];
|
|
134
138
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (!port) {
|
|
139
|
-
throw new Error('OpenCode server not found for directory');
|
|
140
|
-
}
|
|
141
|
-
const response = await fetch(`http://127.0.0.1:${port}/question/${context.requestId}/reply`, {
|
|
142
|
-
method: 'POST',
|
|
143
|
-
headers: { 'Content-Type': 'application/json' },
|
|
144
|
-
body: JSON.stringify({ answers: answersPayload }),
|
|
139
|
+
await clientV2.question.reply({
|
|
140
|
+
requestID: context.requestId,
|
|
141
|
+
answers,
|
|
145
142
|
});
|
|
146
|
-
if (!response.ok) {
|
|
147
|
-
const text = await response.text();
|
|
148
|
-
throw new Error(`Failed to reply to question: ${response.status} ${text}`);
|
|
149
|
-
}
|
|
150
143
|
logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
|
|
151
144
|
}
|
|
152
145
|
catch (error) {
|
|
@@ -182,3 +175,43 @@ export function parseAskUserQuestionTool(part) {
|
|
|
182
175
|
}
|
|
183
176
|
return input;
|
|
184
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Cancel a pending question for a thread (e.g., when user sends a new message).
|
|
180
|
+
* Sends cancellation response to OpenCode so the session can continue.
|
|
181
|
+
*/
|
|
182
|
+
export async function cancelPendingQuestion(threadId) {
|
|
183
|
+
// Find pending question for this thread
|
|
184
|
+
let contextHash;
|
|
185
|
+
let context;
|
|
186
|
+
for (const [hash, ctx] of pendingQuestionContexts) {
|
|
187
|
+
if (ctx.thread.id === threadId) {
|
|
188
|
+
contextHash = hash;
|
|
189
|
+
context = ctx;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!contextHash || !context) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const clientV2 = getOpencodeClientV2(context.directory);
|
|
198
|
+
if (!clientV2) {
|
|
199
|
+
throw new Error('OpenCode server not found for directory');
|
|
200
|
+
}
|
|
201
|
+
// Preserve already-answered questions, mark unanswered as cancelled
|
|
202
|
+
const answers = context.questions.map((_, i) => {
|
|
203
|
+
return context.answers[i] || ['(cancelled - user sent new message)'];
|
|
204
|
+
});
|
|
205
|
+
await clientV2.question.reply({
|
|
206
|
+
requestID: context.requestId,
|
|
207
|
+
answers,
|
|
208
|
+
});
|
|
209
|
+
logger.log(`Cancelled question ${context.requestId} due to new user message`);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
logger.error('Failed to cancel question:', error);
|
|
213
|
+
}
|
|
214
|
+
// Clean up regardless of whether the API call succeeded
|
|
215
|
+
pendingQuestionContexts.delete(contextHash);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
package/dist/discord-bot.js
CHANGED
|
@@ -109,7 +109,10 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
109
109
|
const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
110
110
|
const hasKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
|
|
111
111
|
if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
|
|
112
|
-
await message.
|
|
112
|
+
await message.reply({
|
|
113
|
+
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
114
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
115
|
+
});
|
|
113
116
|
return;
|
|
114
117
|
}
|
|
115
118
|
}
|
|
@@ -296,6 +299,85 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
296
299
|
}
|
|
297
300
|
}
|
|
298
301
|
});
|
|
302
|
+
// Magic prefix used by `kimaki start-session` CLI command to initiate sessions
|
|
303
|
+
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
|
|
304
|
+
// Handle bot-initiated threads created by `kimaki start-session`
|
|
305
|
+
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
306
|
+
try {
|
|
307
|
+
if (!newlyCreated) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// Only handle threads in text channels
|
|
311
|
+
const parent = thread.parent;
|
|
312
|
+
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// Get the starter message to check for magic prefix
|
|
316
|
+
const starterMessage = await thread.fetchStarterMessage().catch(() => null);
|
|
317
|
+
if (!starterMessage) {
|
|
318
|
+
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Only handle messages from this bot with the magic prefix
|
|
322
|
+
if (starterMessage.author.id !== discordClient.user?.id) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
329
|
+
// Extract the prompt (everything after the prefix)
|
|
330
|
+
const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim();
|
|
331
|
+
if (!prompt) {
|
|
332
|
+
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// Extract directory from parent channel topic
|
|
336
|
+
if (!parent.topic) {
|
|
337
|
+
discordLogger.log(`[BOT_SESSION] Parent channel has no topic`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const extracted = extractTagsArrays({
|
|
341
|
+
xml: parent.topic,
|
|
342
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
343
|
+
});
|
|
344
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
345
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
346
|
+
if (!projectDirectory) {
|
|
347
|
+
discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
351
|
+
discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
355
|
+
discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`);
|
|
356
|
+
await thread.send({
|
|
357
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
358
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
359
|
+
});
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
|
|
363
|
+
await handleOpencodeSession({
|
|
364
|
+
prompt,
|
|
365
|
+
thread,
|
|
366
|
+
projectDirectory,
|
|
367
|
+
channelId: parent.id,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', error);
|
|
372
|
+
try {
|
|
373
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
374
|
+
await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS });
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// Ignore send errors
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
299
381
|
await discordClient.login(token);
|
|
300
382
|
const handleShutdown = async (signal, { skipExit = false } = {}) => {
|
|
301
383
|
discordLogger.log(`Received ${signal}, cleaning up...`);
|
package/dist/discord-utils.js
CHANGED
|
@@ -5,6 +5,7 @@ import { ChannelType, } from 'discord.js';
|
|
|
5
5
|
import { Lexer } from 'marked';
|
|
6
6
|
import { extractTagsArrays } from './xml.js';
|
|
7
7
|
import { formatMarkdownTables } from './format-tables.js';
|
|
8
|
+
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
|
|
8
9
|
import { createLogger } from './logger.js';
|
|
9
10
|
const discordLogger = createLogger('DISCORD');
|
|
10
11
|
export const SILENT_MESSAGE_FLAGS = 4 | 4096;
|
|
@@ -92,7 +93,8 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
|
|
|
92
93
|
}
|
|
93
94
|
// calculate overhead for code block markers
|
|
94
95
|
const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0;
|
|
95
|
-
|
|
96
|
+
// ensure at least 10 chars available, even if maxLength is very small
|
|
97
|
+
const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50);
|
|
96
98
|
const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
|
|
97
99
|
for (let i = 0; i < pieces.length; i++) {
|
|
98
100
|
const piece = pieces[i];
|
|
@@ -156,6 +158,7 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
|
|
|
156
158
|
export async function sendThreadMessage(thread, content, options) {
|
|
157
159
|
const MAX_LENGTH = 2000;
|
|
158
160
|
content = formatMarkdownTables(content);
|
|
161
|
+
content = unnestCodeBlocksFromLists(content);
|
|
159
162
|
content = escapeBackticksInCodeBlocks(content);
|
|
160
163
|
// If custom flags provided, send as single message (no chunking)
|
|
161
164
|
if (options?.flags !== undefined) {
|
|
@@ -341,11 +341,19 @@ test('splitMarkdownForDiscord handles very long line inside code block', () => {
|
|
|
341
341
|
\`\`\`
|
|
342
342
|
",
|
|
343
343
|
"\`\`\`js
|
|
344
|
-
|
|
345
|
-
\`\`\`
|
|
344
|
+
veryverylo\`\`\`
|
|
346
345
|
",
|
|
347
346
|
"\`\`\`js
|
|
348
|
-
|
|
347
|
+
nglinethat\`\`\`
|
|
348
|
+
",
|
|
349
|
+
"\`\`\`js
|
|
350
|
+
exceedsmax\`\`\`
|
|
351
|
+
",
|
|
352
|
+
"\`\`\`js
|
|
353
|
+
length
|
|
354
|
+
\`\`\`
|
|
355
|
+
",
|
|
356
|
+
"short
|
|
349
357
|
\`\`\`
|
|
350
358
|
",
|
|
351
359
|
]
|
package/dist/session-handler.js
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
import prettyMilliseconds from 'pretty-ms';
|
|
5
5
|
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
|
|
6
6
|
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
|
|
7
|
-
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js';
|
|
7
|
+
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
|
|
8
8
|
import { formatPart } from './message-formatting.js';
|
|
9
9
|
import { getOpencodeSystemMessage } from './system-message.js';
|
|
10
10
|
import { createLogger } from './logger.js';
|
|
11
11
|
import { isAbortError } from './utils.js';
|
|
12
|
-
import { showAskUserQuestionDropdowns } from './commands/ask-question.js';
|
|
12
|
+
import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js';
|
|
13
13
|
import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
|
|
14
14
|
const sessionLogger = createLogger('SESSION');
|
|
15
15
|
const voiceLogger = createLogger('VOICE');
|
|
@@ -154,6 +154,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
154
154
|
pendingPermissions.delete(thread.id);
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
|
+
// Cancel any pending question tool if user sends a new message
|
|
158
|
+
const questionCancelled = await cancelPendingQuestion(thread.id);
|
|
159
|
+
if (questionCancelled) {
|
|
160
|
+
sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`);
|
|
161
|
+
await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`);
|
|
162
|
+
}
|
|
157
163
|
const abortController = new AbortController();
|
|
158
164
|
abortControllers.set(session.id, abortController);
|
|
159
165
|
if (existingController) {
|
|
@@ -281,7 +287,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
281
287
|
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
|
|
282
288
|
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
283
289
|
lastDisplayedContextPercentage = thresholdCrossed;
|
|
284
|
-
|
|
290
|
+
const chunk = `⬦ context usage ${currentPercentage}%`;
|
|
291
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
|
|
285
292
|
}
|
|
286
293
|
}
|
|
287
294
|
}
|
|
@@ -386,6 +393,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
386
393
|
continue;
|
|
387
394
|
}
|
|
388
395
|
sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
|
|
396
|
+
// Flush any pending text/reasoning parts before showing the dropdown
|
|
397
|
+
// This ensures text the LLM generated before the question tool is shown first
|
|
398
|
+
for (const p of currentParts) {
|
|
399
|
+
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
400
|
+
await sendPartMessage(p);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
389
403
|
await showAskUserQuestionDropdowns({
|
|
390
404
|
thread,
|
|
391
405
|
sessionId: session.id,
|
|
@@ -525,7 +539,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
525
539
|
path: { id: session.id },
|
|
526
540
|
body: {
|
|
527
541
|
parts,
|
|
528
|
-
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
542
|
+
system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
|
|
529
543
|
model: modelParam,
|
|
530
544
|
agent: agentPreference,
|
|
531
545
|
},
|