kimaki 0.4.29 → 0.4.30
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.js +145 -0
- package/dist/discord-bot.js +79 -0
- package/dist/session-handler.js +1 -1
- package/dist/system-message.js +10 -2
- package/package.json +1 -1
- package/src/cli.ts +191 -0
- package/src/discord-bot.ts +93 -0
- package/src/session-handler.ts +1 -1
- package/src/system-message.ts +10 -2
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';
|
|
@@ -683,5 +684,149 @@ cli
|
|
|
683
684
|
process.exit(EXIT_NO_RESTART);
|
|
684
685
|
}
|
|
685
686
|
});
|
|
687
|
+
// Magic prefix used to identify bot-initiated sessions.
|
|
688
|
+
// The running bot will recognize this prefix and start a session.
|
|
689
|
+
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
|
|
690
|
+
cli
|
|
691
|
+
.command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
|
|
692
|
+
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
693
|
+
.option('-p, --prompt <prompt>', 'Initial prompt for the session')
|
|
694
|
+
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
695
|
+
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
696
|
+
.action(async (options) => {
|
|
697
|
+
try {
|
|
698
|
+
const { channel: channelId, prompt, name, appId: optionAppId } = options;
|
|
699
|
+
if (!channelId) {
|
|
700
|
+
cliLogger.error('Channel ID is required. Use --channel <channelId>');
|
|
701
|
+
process.exit(EXIT_NO_RESTART);
|
|
702
|
+
}
|
|
703
|
+
if (!prompt) {
|
|
704
|
+
cliLogger.error('Prompt is required. Use --prompt <prompt>');
|
|
705
|
+
process.exit(EXIT_NO_RESTART);
|
|
706
|
+
}
|
|
707
|
+
// Get bot token from env var or database
|
|
708
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN;
|
|
709
|
+
let botToken;
|
|
710
|
+
let appId = optionAppId;
|
|
711
|
+
if (envToken) {
|
|
712
|
+
botToken = envToken;
|
|
713
|
+
if (!appId) {
|
|
714
|
+
// Try to get app_id from database if available (optional in CI)
|
|
715
|
+
try {
|
|
716
|
+
const db = getDatabase();
|
|
717
|
+
const botRow = db
|
|
718
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
719
|
+
.get();
|
|
720
|
+
appId = botRow?.app_id;
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
// Database might not exist in CI, that's ok
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
// Fall back to database
|
|
729
|
+
try {
|
|
730
|
+
const db = getDatabase();
|
|
731
|
+
const botRow = db
|
|
732
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
733
|
+
.get();
|
|
734
|
+
if (botRow) {
|
|
735
|
+
botToken = botRow.token;
|
|
736
|
+
appId = appId || botRow.app_id;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch (e) {
|
|
740
|
+
// Database error - will fall through to the check below
|
|
741
|
+
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (!botToken) {
|
|
745
|
+
cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
|
|
746
|
+
process.exit(EXIT_NO_RESTART);
|
|
747
|
+
}
|
|
748
|
+
const s = spinner();
|
|
749
|
+
s.start('Fetching channel info...');
|
|
750
|
+
// Get channel info to extract directory from topic
|
|
751
|
+
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
|
752
|
+
headers: {
|
|
753
|
+
'Authorization': `Bot ${botToken}`,
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
if (!channelResponse.ok) {
|
|
757
|
+
const error = await channelResponse.text();
|
|
758
|
+
s.stop('Failed to fetch channel');
|
|
759
|
+
throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
|
|
760
|
+
}
|
|
761
|
+
const channelData = await channelResponse.json();
|
|
762
|
+
if (!channelData.topic) {
|
|
763
|
+
s.stop('Channel has no topic');
|
|
764
|
+
throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`);
|
|
765
|
+
}
|
|
766
|
+
const extracted = extractTagsArrays({
|
|
767
|
+
xml: channelData.topic,
|
|
768
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
769
|
+
});
|
|
770
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
771
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
772
|
+
if (!projectDirectory) {
|
|
773
|
+
s.stop('No kimaki.directory tag found');
|
|
774
|
+
throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`);
|
|
775
|
+
}
|
|
776
|
+
// Verify app ID matches if both are present
|
|
777
|
+
if (channelAppId && appId && channelAppId !== appId) {
|
|
778
|
+
s.stop('Channel belongs to different bot');
|
|
779
|
+
throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`);
|
|
780
|
+
}
|
|
781
|
+
s.message('Creating starter message...');
|
|
782
|
+
// Create starter message with magic prefix
|
|
783
|
+
// The full prompt goes in the message so the bot can read it
|
|
784
|
+
const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
785
|
+
method: 'POST',
|
|
786
|
+
headers: {
|
|
787
|
+
'Authorization': `Bot ${botToken}`,
|
|
788
|
+
'Content-Type': 'application/json',
|
|
789
|
+
},
|
|
790
|
+
body: JSON.stringify({
|
|
791
|
+
content: `${BOT_SESSION_PREFIX}\n${prompt}`,
|
|
792
|
+
}),
|
|
793
|
+
});
|
|
794
|
+
if (!starterMessageResponse.ok) {
|
|
795
|
+
const error = await starterMessageResponse.text();
|
|
796
|
+
s.stop('Failed to create message');
|
|
797
|
+
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
|
|
798
|
+
}
|
|
799
|
+
const starterMessage = await starterMessageResponse.json();
|
|
800
|
+
s.message('Creating thread...');
|
|
801
|
+
// Create thread from the message
|
|
802
|
+
const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
|
|
803
|
+
const threadResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`, {
|
|
804
|
+
method: 'POST',
|
|
805
|
+
headers: {
|
|
806
|
+
'Authorization': `Bot ${botToken}`,
|
|
807
|
+
'Content-Type': 'application/json',
|
|
808
|
+
},
|
|
809
|
+
body: JSON.stringify({
|
|
810
|
+
name: threadName.slice(0, 100),
|
|
811
|
+
auto_archive_duration: 1440, // 1 day
|
|
812
|
+
}),
|
|
813
|
+
});
|
|
814
|
+
if (!threadResponse.ok) {
|
|
815
|
+
const error = await threadResponse.text();
|
|
816
|
+
s.stop('Failed to create thread');
|
|
817
|
+
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
|
|
818
|
+
}
|
|
819
|
+
const threadData = await threadResponse.json();
|
|
820
|
+
s.stop('Thread created!');
|
|
821
|
+
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
822
|
+
note(`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`, '✅ Thread Created');
|
|
823
|
+
console.log(threadUrl);
|
|
824
|
+
process.exit(0);
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
828
|
+
process.exit(EXIT_NO_RESTART);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
686
831
|
cli.help();
|
|
687
832
|
cli.parse();
|
package/dist/discord-bot.js
CHANGED
|
@@ -296,6 +296,85 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
});
|
|
299
|
+
// Magic prefix used by `kimaki start-session` CLI command to initiate sessions
|
|
300
|
+
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
|
|
301
|
+
// Handle bot-initiated threads created by `kimaki start-session`
|
|
302
|
+
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
303
|
+
try {
|
|
304
|
+
if (!newlyCreated) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Only handle threads in text channels
|
|
308
|
+
const parent = thread.parent;
|
|
309
|
+
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Get the starter message to check for magic prefix
|
|
313
|
+
const starterMessage = await thread.fetchStarterMessage().catch(() => null);
|
|
314
|
+
if (!starterMessage) {
|
|
315
|
+
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Only handle messages from this bot with the magic prefix
|
|
319
|
+
if (starterMessage.author.id !== discordClient.user?.id) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
326
|
+
// Extract the prompt (everything after the prefix)
|
|
327
|
+
const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim();
|
|
328
|
+
if (!prompt) {
|
|
329
|
+
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Extract directory from parent channel topic
|
|
333
|
+
if (!parent.topic) {
|
|
334
|
+
discordLogger.log(`[BOT_SESSION] Parent channel has no topic`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const extracted = extractTagsArrays({
|
|
338
|
+
xml: parent.topic,
|
|
339
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
340
|
+
});
|
|
341
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
|
|
342
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim();
|
|
343
|
+
if (!projectDirectory) {
|
|
344
|
+
discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
348
|
+
discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
352
|
+
discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`);
|
|
353
|
+
await thread.send({
|
|
354
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
355
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`);
|
|
360
|
+
await handleOpencodeSession({
|
|
361
|
+
prompt,
|
|
362
|
+
thread,
|
|
363
|
+
projectDirectory,
|
|
364
|
+
channelId: parent.id,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', error);
|
|
369
|
+
try {
|
|
370
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
371
|
+
await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS });
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// Ignore send errors
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
299
378
|
await discordClient.login(token);
|
|
300
379
|
const handleShutdown = async (signal, { skipExit = false } = {}) => {
|
|
301
380
|
discordLogger.log(`Received ${signal}, cleaning up...`);
|
package/dist/session-handler.js
CHANGED
|
@@ -525,7 +525,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
525
525
|
path: { id: session.id },
|
|
526
526
|
body: {
|
|
527
527
|
parts,
|
|
528
|
-
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
528
|
+
system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
|
|
529
529
|
model: modelParam,
|
|
530
530
|
agent: agentPreference,
|
|
531
531
|
},
|
package/dist/system-message.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// OpenCode system prompt generator.
|
|
2
2
|
// Creates the system message injected into every OpenCode session,
|
|
3
3
|
// including Discord-specific formatting rules, diff commands, and permissions info.
|
|
4
|
-
export function getOpencodeSystemMessage({ sessionId }) {
|
|
4
|
+
export function getOpencodeSystemMessage({ sessionId, channelId }) {
|
|
5
5
|
return `
|
|
6
6
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
7
7
|
|
|
8
8
|
The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
|
|
9
9
|
|
|
10
|
-
Your current OpenCode session ID is: ${sessionId}
|
|
10
|
+
Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}
|
|
11
11
|
|
|
12
12
|
## permissions
|
|
13
13
|
|
|
@@ -22,7 +22,15 @@ Only users with these Discord permissions can send messages to the bot:
|
|
|
22
22
|
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
23
23
|
|
|
24
24
|
npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
|
|
25
|
+
${channelId ? `
|
|
26
|
+
## starting new sessions from CLI
|
|
25
27
|
|
|
28
|
+
To start a new thread/session in this channel programmatically, run:
|
|
29
|
+
|
|
30
|
+
npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
|
|
31
|
+
|
|
32
|
+
This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
|
|
33
|
+
` : ''}
|
|
26
34
|
## showing diffs
|
|
27
35
|
|
|
28
36
|
IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.
|
package/package.json
CHANGED
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')
|
|
@@ -958,6 +959,196 @@ cli
|
|
|
958
959
|
})
|
|
959
960
|
|
|
960
961
|
|
|
962
|
+
// Magic prefix used to identify bot-initiated sessions.
|
|
963
|
+
// The running bot will recognize this prefix and start a session.
|
|
964
|
+
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
|
|
965
|
+
|
|
966
|
+
cli
|
|
967
|
+
.command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
|
|
968
|
+
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
969
|
+
.option('-p, --prompt <prompt>', 'Initial prompt for the session')
|
|
970
|
+
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
971
|
+
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
972
|
+
.action(async (options: { channel?: string; prompt?: string; name?: string; appId?: string }) => {
|
|
973
|
+
try {
|
|
974
|
+
const { channel: channelId, prompt, name, appId: optionAppId } = options
|
|
975
|
+
|
|
976
|
+
if (!channelId) {
|
|
977
|
+
cliLogger.error('Channel ID is required. Use --channel <channelId>')
|
|
978
|
+
process.exit(EXIT_NO_RESTART)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (!prompt) {
|
|
982
|
+
cliLogger.error('Prompt is required. Use --prompt <prompt>')
|
|
983
|
+
process.exit(EXIT_NO_RESTART)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Get bot token from env var or database
|
|
987
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN
|
|
988
|
+
let botToken: string | undefined
|
|
989
|
+
let appId: string | undefined = optionAppId
|
|
990
|
+
|
|
991
|
+
if (envToken) {
|
|
992
|
+
botToken = envToken
|
|
993
|
+
if (!appId) {
|
|
994
|
+
// Try to get app_id from database if available (optional in CI)
|
|
995
|
+
try {
|
|
996
|
+
const db = getDatabase()
|
|
997
|
+
const botRow = db
|
|
998
|
+
.prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
999
|
+
.get() as { app_id: string } | undefined
|
|
1000
|
+
appId = botRow?.app_id
|
|
1001
|
+
} catch {
|
|
1002
|
+
// Database might not exist in CI, that's ok
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
} else {
|
|
1006
|
+
// Fall back to database
|
|
1007
|
+
try {
|
|
1008
|
+
const db = getDatabase()
|
|
1009
|
+
const botRow = db
|
|
1010
|
+
.prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
|
|
1011
|
+
.get() as { app_id: string; token: string } | undefined
|
|
1012
|
+
|
|
1013
|
+
if (botRow) {
|
|
1014
|
+
botToken = botRow.token
|
|
1015
|
+
appId = appId || botRow.app_id
|
|
1016
|
+
}
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
// Database error - will fall through to the check below
|
|
1019
|
+
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e))
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (!botToken) {
|
|
1024
|
+
cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.')
|
|
1025
|
+
process.exit(EXIT_NO_RESTART)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const s = spinner()
|
|
1029
|
+
s.start('Fetching channel info...')
|
|
1030
|
+
|
|
1031
|
+
// Get channel info to extract directory from topic
|
|
1032
|
+
const channelResponse = await fetch(
|
|
1033
|
+
`https://discord.com/api/v10/channels/${channelId}`,
|
|
1034
|
+
{
|
|
1035
|
+
headers: {
|
|
1036
|
+
'Authorization': `Bot ${botToken}`,
|
|
1037
|
+
},
|
|
1038
|
+
}
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
if (!channelResponse.ok) {
|
|
1042
|
+
const error = await channelResponse.text()
|
|
1043
|
+
s.stop('Failed to fetch channel')
|
|
1044
|
+
throw new Error(`Discord API error: ${channelResponse.status} - ${error}`)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const channelData = await channelResponse.json() as {
|
|
1048
|
+
id: string
|
|
1049
|
+
name: string
|
|
1050
|
+
topic?: string
|
|
1051
|
+
guild_id: string
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (!channelData.topic) {
|
|
1055
|
+
s.stop('Channel has no topic')
|
|
1056
|
+
throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const extracted = extractTagsArrays({
|
|
1060
|
+
xml: channelData.topic,
|
|
1061
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
1065
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
1066
|
+
|
|
1067
|
+
if (!projectDirectory) {
|
|
1068
|
+
s.stop('No kimaki.directory tag found')
|
|
1069
|
+
throw new Error(`Channel #${channelData.name} has no <kimaki.directory> tag in topic.`)
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Verify app ID matches if both are present
|
|
1073
|
+
if (channelAppId && appId && channelAppId !== appId) {
|
|
1074
|
+
s.stop('Channel belongs to different bot')
|
|
1075
|
+
throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
s.message('Creating starter message...')
|
|
1079
|
+
|
|
1080
|
+
// Create starter message with magic prefix
|
|
1081
|
+
// The full prompt goes in the message so the bot can read it
|
|
1082
|
+
const starterMessageResponse = await fetch(
|
|
1083
|
+
`https://discord.com/api/v10/channels/${channelId}/messages`,
|
|
1084
|
+
{
|
|
1085
|
+
method: 'POST',
|
|
1086
|
+
headers: {
|
|
1087
|
+
'Authorization': `Bot ${botToken}`,
|
|
1088
|
+
'Content-Type': 'application/json',
|
|
1089
|
+
},
|
|
1090
|
+
body: JSON.stringify({
|
|
1091
|
+
content: `${BOT_SESSION_PREFIX}\n${prompt}`,
|
|
1092
|
+
}),
|
|
1093
|
+
}
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
if (!starterMessageResponse.ok) {
|
|
1097
|
+
const error = await starterMessageResponse.text()
|
|
1098
|
+
s.stop('Failed to create message')
|
|
1099
|
+
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const starterMessage = await starterMessageResponse.json() as { id: string }
|
|
1103
|
+
|
|
1104
|
+
s.message('Creating thread...')
|
|
1105
|
+
|
|
1106
|
+
// Create thread from the message
|
|
1107
|
+
const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt)
|
|
1108
|
+
const threadResponse = await fetch(
|
|
1109
|
+
`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`,
|
|
1110
|
+
{
|
|
1111
|
+
method: 'POST',
|
|
1112
|
+
headers: {
|
|
1113
|
+
'Authorization': `Bot ${botToken}`,
|
|
1114
|
+
'Content-Type': 'application/json',
|
|
1115
|
+
},
|
|
1116
|
+
body: JSON.stringify({
|
|
1117
|
+
name: threadName.slice(0, 100),
|
|
1118
|
+
auto_archive_duration: 1440, // 1 day
|
|
1119
|
+
}),
|
|
1120
|
+
}
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
if (!threadResponse.ok) {
|
|
1124
|
+
const error = await threadResponse.text()
|
|
1125
|
+
s.stop('Failed to create thread')
|
|
1126
|
+
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const threadData = await threadResponse.json() as { id: string; name: string }
|
|
1130
|
+
|
|
1131
|
+
s.stop('Thread created!')
|
|
1132
|
+
|
|
1133
|
+
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
|
|
1134
|
+
|
|
1135
|
+
note(
|
|
1136
|
+
`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`,
|
|
1137
|
+
'✅ Thread Created',
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
console.log(threadUrl)
|
|
1141
|
+
|
|
1142
|
+
process.exit(0)
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
cliLogger.error(
|
|
1145
|
+
'Error:',
|
|
1146
|
+
error instanceof Error ? error.message : String(error),
|
|
1147
|
+
)
|
|
1148
|
+
process.exit(EXIT_NO_RESTART)
|
|
1149
|
+
}
|
|
1150
|
+
})
|
|
1151
|
+
|
|
961
1152
|
|
|
962
1153
|
cli.help()
|
|
963
1154
|
cli.parse()
|
package/src/discord-bot.ts
CHANGED
|
@@ -412,6 +412,99 @@ export async function startDiscordBot({
|
|
|
412
412
|
}
|
|
413
413
|
})
|
|
414
414
|
|
|
415
|
+
// Magic prefix used by `kimaki start-session` CLI command to initiate sessions
|
|
416
|
+
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
|
|
417
|
+
|
|
418
|
+
// Handle bot-initiated threads created by `kimaki start-session`
|
|
419
|
+
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
420
|
+
try {
|
|
421
|
+
if (!newlyCreated) {
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Only handle threads in text channels
|
|
426
|
+
const parent = thread.parent as TextChannel | null
|
|
427
|
+
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Get the starter message to check for magic prefix
|
|
432
|
+
const starterMessage = await thread.fetchStarterMessage().catch(() => null)
|
|
433
|
+
if (!starterMessage) {
|
|
434
|
+
discordLogger.log(`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Only handle messages from this bot with the magic prefix
|
|
439
|
+
if (starterMessage.author.id !== discordClient.user?.id) {
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!starterMessage.content.startsWith(BOT_SESSION_PREFIX)) {
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`)
|
|
448
|
+
|
|
449
|
+
// Extract the prompt (everything after the prefix)
|
|
450
|
+
const prompt = starterMessage.content.slice(BOT_SESSION_PREFIX.length).trim()
|
|
451
|
+
if (!prompt) {
|
|
452
|
+
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Extract directory from parent channel topic
|
|
457
|
+
if (!parent.topic) {
|
|
458
|
+
discordLogger.log(`[BOT_SESSION] Parent channel has no topic`)
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const extracted = extractTagsArrays({
|
|
463
|
+
xml: parent.topic,
|
|
464
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
468
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
469
|
+
|
|
470
|
+
if (!projectDirectory) {
|
|
471
|
+
discordLogger.log(`[BOT_SESSION] No kimaki.directory in parent channel topic`)
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
476
|
+
discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`)
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
481
|
+
discordLogger.error(`[BOT_SESSION] Directory does not exist: ${projectDirectory}`)
|
|
482
|
+
await thread.send({
|
|
483
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory)}`,
|
|
484
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
485
|
+
})
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
discordLogger.log(`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`)
|
|
490
|
+
|
|
491
|
+
await handleOpencodeSession({
|
|
492
|
+
prompt,
|
|
493
|
+
thread,
|
|
494
|
+
projectDirectory,
|
|
495
|
+
channelId: parent.id,
|
|
496
|
+
})
|
|
497
|
+
} catch (error) {
|
|
498
|
+
voiceLogger.error('[BOT_SESSION] Error handling bot-initiated thread:', error)
|
|
499
|
+
try {
|
|
500
|
+
const errMsg = error instanceof Error ? error.message : String(error)
|
|
501
|
+
await thread.send({ content: `Error: ${errMsg}`, flags: SILENT_MESSAGE_FLAGS })
|
|
502
|
+
} catch {
|
|
503
|
+
// Ignore send errors
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
|
|
415
508
|
await discordClient.login(token)
|
|
416
509
|
|
|
417
510
|
const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
|
package/src/session-handler.ts
CHANGED
|
@@ -690,7 +690,7 @@ export async function handleOpencodeSession({
|
|
|
690
690
|
path: { id: session.id },
|
|
691
691
|
body: {
|
|
692
692
|
parts,
|
|
693
|
-
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
693
|
+
system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
|
|
694
694
|
model: modelParam,
|
|
695
695
|
agent: agentPreference,
|
|
696
696
|
},
|
package/src/system-message.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
// Creates the system message injected into every OpenCode session,
|
|
3
3
|
// including Discord-specific formatting rules, diff commands, and permissions info.
|
|
4
4
|
|
|
5
|
-
export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
|
|
5
|
+
export function getOpencodeSystemMessage({ sessionId, channelId }: { sessionId: string; channelId?: string }) {
|
|
6
6
|
return `
|
|
7
7
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
8
8
|
|
|
9
9
|
The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
|
|
10
10
|
|
|
11
|
-
Your current OpenCode session ID is: ${sessionId}
|
|
11
|
+
Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}
|
|
12
12
|
|
|
13
13
|
## permissions
|
|
14
14
|
|
|
@@ -23,7 +23,15 @@ Only users with these Discord permissions can send messages to the bot:
|
|
|
23
23
|
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
24
24
|
|
|
25
25
|
npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
|
|
26
|
+
${channelId ? `
|
|
27
|
+
## starting new sessions from CLI
|
|
26
28
|
|
|
29
|
+
To start a new thread/session in this channel programmatically, run:
|
|
30
|
+
|
|
31
|
+
npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
|
|
32
|
+
|
|
33
|
+
This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
|
|
34
|
+
` : ''}
|
|
27
35
|
## showing diffs
|
|
28
36
|
|
|
29
37
|
IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.
|