kimaki 0.4.28 → 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 +173 -10
- package/dist/commands/add-project.js +11 -2
- package/dist/commands/create-new-project.js +6 -6
- package/dist/config.js +59 -0
- package/dist/database.js +6 -6
- package/dist/discord-bot.js +79 -0
- package/dist/session-handler.js +1 -1
- package/dist/system-message.js +10 -2
- package/dist/utils.js +8 -0
- package/package.json +1 -1
- package/src/cli.ts +225 -12
- package/src/commands/add-project.ts +11 -4
- package/src/commands/create-new-project.ts +6 -6
- package/src/config.ts +71 -0
- package/src/database.ts +6 -6
- package/src/discord-bot.ts +93 -0
- package/src/session-handler.ts +1 -1
- package/src/system-message.ts +10 -2
- package/src/utils.ts +9 -0
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// project channel creation, and launching the bot with opencode integration.
|
|
5
5
|
import { cac } from 'cac';
|
|
6
6
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
|
|
7
|
-
import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
|
|
7
|
+
import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js';
|
|
8
8
|
import { getChannelsWithDescriptions, createDiscordClient, getDatabase, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
|
|
9
9
|
import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuilder, } from 'discord.js';
|
|
10
10
|
import path from 'node:path';
|
|
@@ -12,10 +12,11 @@ import fs from 'node:fs';
|
|
|
12
12
|
import { createLogger } from './logger.js';
|
|
13
13
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
14
14
|
import http from 'node:http';
|
|
15
|
+
import { setDataDir, getDataDir, getLockPort } from './config.js';
|
|
16
|
+
import { extractTagsArrays } from './xml.js';
|
|
15
17
|
const cliLogger = createLogger('CLI');
|
|
16
18
|
const cli = cac('kimaki');
|
|
17
19
|
process.title = 'kimaki';
|
|
18
|
-
const LOCK_PORT = 29988;
|
|
19
20
|
async function killProcessOnPort(port) {
|
|
20
21
|
const isWindows = process.platform === 'win32';
|
|
21
22
|
const myPid = process.pid;
|
|
@@ -58,13 +59,14 @@ async function killProcessOnPort(port) {
|
|
|
58
59
|
return false;
|
|
59
60
|
}
|
|
60
61
|
async function checkSingleInstance() {
|
|
62
|
+
const lockPort = getLockPort();
|
|
61
63
|
try {
|
|
62
|
-
const response = await fetch(`http://127.0.0.1:${
|
|
64
|
+
const response = await fetch(`http://127.0.0.1:${lockPort}`, {
|
|
63
65
|
signal: AbortSignal.timeout(1000),
|
|
64
66
|
});
|
|
65
67
|
if (response.ok) {
|
|
66
|
-
cliLogger.log(
|
|
67
|
-
await killProcessOnPort(
|
|
68
|
+
cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`);
|
|
69
|
+
await killProcessOnPort(lockPort);
|
|
68
70
|
// Wait a moment for port to be released
|
|
69
71
|
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
70
72
|
}
|
|
@@ -74,22 +76,24 @@ async function checkSingleInstance() {
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
async function startLockServer() {
|
|
79
|
+
const lockPort = getLockPort();
|
|
77
80
|
return new Promise((resolve, reject) => {
|
|
78
81
|
const server = http.createServer((req, res) => {
|
|
79
82
|
res.writeHead(200);
|
|
80
83
|
res.end('kimaki');
|
|
81
84
|
});
|
|
82
|
-
server.listen(
|
|
85
|
+
server.listen(lockPort, '127.0.0.1');
|
|
83
86
|
server.once('listening', () => {
|
|
87
|
+
cliLogger.debug(`Lock server started on port ${lockPort}`);
|
|
84
88
|
resolve();
|
|
85
89
|
});
|
|
86
90
|
server.on('error', async (err) => {
|
|
87
91
|
if (err.code === 'EADDRINUSE') {
|
|
88
92
|
cliLogger.log('Port still in use, retrying...');
|
|
89
|
-
await killProcessOnPort(
|
|
93
|
+
await killProcessOnPort(lockPort);
|
|
90
94
|
await new Promise((r) => { setTimeout(r, 500); });
|
|
91
95
|
// Retry once
|
|
92
|
-
server.listen(
|
|
96
|
+
server.listen(lockPort, '127.0.0.1');
|
|
93
97
|
}
|
|
94
98
|
else {
|
|
95
99
|
reject(err);
|
|
@@ -461,7 +465,15 @@ async function run({ restart, addChannels }) {
|
|
|
461
465
|
.filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
|
|
462
466
|
.map((ch) => ch.kimakiDirectory)
|
|
463
467
|
.filter(Boolean));
|
|
464
|
-
const availableProjects = deduplicateByKey(projects.filter((project) =>
|
|
468
|
+
const availableProjects = deduplicateByKey(projects.filter((project) => {
|
|
469
|
+
if (existingDirs.includes(project.worktree)) {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
if (path.basename(project.worktree).startsWith('opencode-test-')) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
}), (x) => x.worktree);
|
|
465
477
|
if (availableProjects.length === 0) {
|
|
466
478
|
note('All OpenCode projects already have Discord channels', 'No New Projects');
|
|
467
479
|
}
|
|
@@ -471,7 +483,7 @@ async function run({ restart, addChannels }) {
|
|
|
471
483
|
message: 'Select projects to create Discord channels for:',
|
|
472
484
|
options: availableProjects.map((project) => ({
|
|
473
485
|
value: project.id,
|
|
474
|
-
label: `${path.basename(project.worktree)} (${project.worktree})`,
|
|
486
|
+
label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
|
|
475
487
|
})),
|
|
476
488
|
required: false,
|
|
477
489
|
});
|
|
@@ -585,13 +597,20 @@ cli
|
|
|
585
597
|
.command('', 'Set up and run the Kimaki Discord bot')
|
|
586
598
|
.option('--restart', 'Prompt for new credentials even if saved')
|
|
587
599
|
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
600
|
+
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
588
601
|
.action(async (options) => {
|
|
589
602
|
try {
|
|
603
|
+
// Set data directory early, before any database access
|
|
604
|
+
if (options.dataDir) {
|
|
605
|
+
setDataDir(options.dataDir);
|
|
606
|
+
cliLogger.log(`Using data directory: ${getDataDir()}`);
|
|
607
|
+
}
|
|
590
608
|
await checkSingleInstance();
|
|
591
609
|
await startLockServer();
|
|
592
610
|
await run({
|
|
593
611
|
restart: options.restart,
|
|
594
612
|
addChannels: options.addChannels,
|
|
613
|
+
dataDir: options.dataDir,
|
|
595
614
|
});
|
|
596
615
|
}
|
|
597
616
|
catch (error) {
|
|
@@ -665,5 +684,149 @@ cli
|
|
|
665
684
|
process.exit(EXIT_NO_RESTART);
|
|
666
685
|
}
|
|
667
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
|
+
});
|
|
668
831
|
cli.help();
|
|
669
832
|
cli.parse();
|
|
@@ -5,6 +5,7 @@ import { getDatabase } from '../database.js';
|
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
6
|
import { createProjectChannels } from '../channel-management.js';
|
|
7
7
|
import { createLogger } from '../logger.js';
|
|
8
|
+
import { abbreviatePath } from '../utils.js';
|
|
8
9
|
const logger = createLogger('ADD-PROJECT');
|
|
9
10
|
export async function handleAddProjectCommand({ command, appId, }) {
|
|
10
11
|
await command.deferReply({ ephemeral: false });
|
|
@@ -69,7 +70,15 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
|
|
|
69
70
|
.prepare('SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?')
|
|
70
71
|
.all('text');
|
|
71
72
|
const existingDirSet = new Set(existingDirs.map((row) => row.directory));
|
|
72
|
-
const availableProjects = projectsResponse.data.filter((project) =>
|
|
73
|
+
const availableProjects = projectsResponse.data.filter((project) => {
|
|
74
|
+
if (existingDirSet.has(project.worktree)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (path.basename(project.worktree).startsWith('opencode-test-')) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
73
82
|
const projects = availableProjects
|
|
74
83
|
.filter((project) => {
|
|
75
84
|
const baseName = path.basename(project.worktree);
|
|
@@ -83,7 +92,7 @@ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
|
|
|
83
92
|
})
|
|
84
93
|
.slice(0, 25)
|
|
85
94
|
.map((project) => {
|
|
86
|
-
const name = `${path.basename(project.worktree)} (${project.worktree})`;
|
|
95
|
+
const name = `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`;
|
|
87
96
|
return {
|
|
88
97
|
name: name.length > 100 ? name.slice(0, 99) + '…' : name,
|
|
89
98
|
value: project.id,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// /create-new-project command - Create a new project folder, initialize git, and start a session.
|
|
2
2
|
import { ChannelType } from 'discord.js';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
5
|
+
import { getProjectsDir } from '../config.js';
|
|
6
6
|
import { createProjectChannels } from '../channel-management.js';
|
|
7
7
|
import { handleOpencodeSession } from '../session-handler.js';
|
|
8
8
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
@@ -31,12 +31,12 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
|
|
|
31
31
|
await command.editReply('Invalid project name');
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
-
const
|
|
35
|
-
const projectDirectory = path.join(
|
|
34
|
+
const projectsDir = getProjectsDir();
|
|
35
|
+
const projectDirectory = path.join(projectsDir, sanitizedName);
|
|
36
36
|
try {
|
|
37
|
-
if (!fs.existsSync(
|
|
38
|
-
fs.mkdirSync(
|
|
39
|
-
logger.log(`Created
|
|
37
|
+
if (!fs.existsSync(projectsDir)) {
|
|
38
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
39
|
+
logger.log(`Created projects directory: ${projectsDir}`);
|
|
40
40
|
}
|
|
41
41
|
if (fs.existsSync(projectDirectory)) {
|
|
42
42
|
await command.editReply(`Project directory already exists: ${projectDirectory}`);
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Runtime configuration for Kimaki bot.
|
|
2
|
+
// Stores data directory path and provides accessors for other modules.
|
|
3
|
+
// Must be initialized before database or other path-dependent modules are used.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki');
|
|
8
|
+
let dataDir = null;
|
|
9
|
+
/**
|
|
10
|
+
* Get the data directory path.
|
|
11
|
+
* Falls back to ~/.kimaki if not explicitly set.
|
|
12
|
+
*/
|
|
13
|
+
export function getDataDir() {
|
|
14
|
+
if (!dataDir) {
|
|
15
|
+
dataDir = DEFAULT_DATA_DIR;
|
|
16
|
+
}
|
|
17
|
+
return dataDir;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Set the data directory path.
|
|
21
|
+
* Creates the directory if it doesn't exist.
|
|
22
|
+
* Must be called before any database or path-dependent operations.
|
|
23
|
+
*/
|
|
24
|
+
export function setDataDir(dir) {
|
|
25
|
+
const resolvedDir = path.resolve(dir);
|
|
26
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
27
|
+
fs.mkdirSync(resolvedDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
dataDir = resolvedDir;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Get the projects directory path (for /create-new-project command).
|
|
33
|
+
* Returns <dataDir>/projects
|
|
34
|
+
*/
|
|
35
|
+
export function getProjectsDir() {
|
|
36
|
+
return path.join(getDataDir(), 'projects');
|
|
37
|
+
}
|
|
38
|
+
const DEFAULT_LOCK_PORT = 29988;
|
|
39
|
+
/**
|
|
40
|
+
* Derive a lock port from the data directory path.
|
|
41
|
+
* Returns 29988 for the default ~/.kimaki directory (backwards compatible).
|
|
42
|
+
* For custom data dirs, uses a hash to generate a port in the range 30000-39999.
|
|
43
|
+
*/
|
|
44
|
+
export function getLockPort() {
|
|
45
|
+
const dir = getDataDir();
|
|
46
|
+
// Use original port for default data dir (backwards compatible)
|
|
47
|
+
if (dir === DEFAULT_DATA_DIR) {
|
|
48
|
+
return DEFAULT_LOCK_PORT;
|
|
49
|
+
}
|
|
50
|
+
// Hash-based port for custom data dirs
|
|
51
|
+
let hash = 0;
|
|
52
|
+
for (let i = 0; i < dir.length; i++) {
|
|
53
|
+
const char = dir.charCodeAt(i);
|
|
54
|
+
hash = ((hash << 5) - hash) + char;
|
|
55
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
56
|
+
}
|
|
57
|
+
// Map to port range 30000-39999
|
|
58
|
+
return 30000 + (Math.abs(hash) % 10000);
|
|
59
|
+
}
|
package/dist/database.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
// SQLite database manager for persistent bot state.
|
|
2
2
|
// Stores thread-session mappings, bot tokens, channel directories,
|
|
3
|
-
// API keys, and model preferences in
|
|
3
|
+
// API keys, and model preferences in <dataDir>/discord-sessions.db.
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import os from 'node:os';
|
|
7
6
|
import path from 'node:path';
|
|
8
7
|
import { createLogger } from './logger.js';
|
|
8
|
+
import { getDataDir } from './config.js';
|
|
9
9
|
const dbLogger = createLogger('DB');
|
|
10
10
|
let db = null;
|
|
11
11
|
export function getDatabase() {
|
|
12
12
|
if (!db) {
|
|
13
|
-
const
|
|
13
|
+
const dataDir = getDataDir();
|
|
14
14
|
try {
|
|
15
|
-
fs.mkdirSync(
|
|
15
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
16
16
|
}
|
|
17
17
|
catch (error) {
|
|
18
|
-
dbLogger.error(
|
|
18
|
+
dbLogger.error(`Failed to create data directory ${dataDir}:`, error);
|
|
19
19
|
}
|
|
20
|
-
const dbPath = path.join(
|
|
20
|
+
const dbPath = path.join(dataDir, 'discord-sessions.db');
|
|
21
21
|
dbLogger.log(`Opening database at: ${dbPath}`);
|
|
22
22
|
db = new Database(dbPath);
|
|
23
23
|
db.exec(`
|
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/dist/utils.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// General utility functions for the bot.
|
|
2
2
|
// Includes Discord OAuth URL generation, array deduplication,
|
|
3
3
|
// abort error detection, and date/time formatting helpers.
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import { PermissionsBitField } from 'discord.js';
|
|
5
6
|
export function generateBotInstallUrl({ clientId, permissions = [
|
|
6
7
|
PermissionsBitField.Flags.ViewChannel,
|
|
@@ -83,3 +84,10 @@ const dtf = new Intl.DateTimeFormat('en-US', {
|
|
|
83
84
|
export function formatDateTime(date) {
|
|
84
85
|
return dtf.format(date);
|
|
85
86
|
}
|
|
87
|
+
export function abbreviatePath(fullPath) {
|
|
88
|
+
const home = os.homedir();
|
|
89
|
+
if (fullPath.startsWith(home)) {
|
|
90
|
+
return '~' + fullPath.slice(home.length);
|
|
91
|
+
}
|
|
92
|
+
return fullPath;
|
|
93
|
+
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
multiselect,
|
|
17
17
|
spinner,
|
|
18
18
|
} from '@clack/prompts'
|
|
19
|
-
import { deduplicateByKey, generateBotInstallUrl } from './utils.js'
|
|
19
|
+
import { deduplicateByKey, generateBotInstallUrl, abbreviatePath } from './utils.js'
|
|
20
20
|
import {
|
|
21
21
|
getChannelsWithDescriptions,
|
|
22
22
|
createDiscordClient,
|
|
@@ -45,14 +45,14 @@ import fs from 'node:fs'
|
|
|
45
45
|
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
|
+
import { setDataDir, getDataDir, getLockPort } from './config.js'
|
|
49
|
+
import { extractTagsArrays } from './xml.js'
|
|
48
50
|
|
|
49
51
|
const cliLogger = createLogger('CLI')
|
|
50
52
|
const cli = cac('kimaki')
|
|
51
53
|
|
|
52
54
|
process.title = 'kimaki'
|
|
53
55
|
|
|
54
|
-
const LOCK_PORT = 29988
|
|
55
|
-
|
|
56
56
|
async function killProcessOnPort(port: number): Promise<boolean> {
|
|
57
57
|
const isWindows = process.platform === 'win32'
|
|
58
58
|
const myPid = process.pid
|
|
@@ -95,13 +95,14 @@ async function killProcessOnPort(port: number): Promise<boolean> {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
async function checkSingleInstance(): Promise<void> {
|
|
98
|
+
const lockPort = getLockPort()
|
|
98
99
|
try {
|
|
99
|
-
const response = await fetch(`http://127.0.0.1:${
|
|
100
|
+
const response = await fetch(`http://127.0.0.1:${lockPort}`, {
|
|
100
101
|
signal: AbortSignal.timeout(1000),
|
|
101
102
|
})
|
|
102
103
|
if (response.ok) {
|
|
103
|
-
cliLogger.log(
|
|
104
|
-
await killProcessOnPort(
|
|
104
|
+
cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`)
|
|
105
|
+
await killProcessOnPort(lockPort)
|
|
105
106
|
// Wait a moment for port to be released
|
|
106
107
|
await new Promise((resolve) => { setTimeout(resolve, 500) })
|
|
107
108
|
}
|
|
@@ -111,22 +112,24 @@ async function checkSingleInstance(): Promise<void> {
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
async function startLockServer(): Promise<void> {
|
|
115
|
+
const lockPort = getLockPort()
|
|
114
116
|
return new Promise((resolve, reject) => {
|
|
115
117
|
const server = http.createServer((req, res) => {
|
|
116
118
|
res.writeHead(200)
|
|
117
119
|
res.end('kimaki')
|
|
118
120
|
})
|
|
119
|
-
server.listen(
|
|
121
|
+
server.listen(lockPort, '127.0.0.1')
|
|
120
122
|
server.once('listening', () => {
|
|
123
|
+
cliLogger.debug(`Lock server started on port ${lockPort}`)
|
|
121
124
|
resolve()
|
|
122
125
|
})
|
|
123
126
|
server.on('error', async (err: NodeJS.ErrnoException) => {
|
|
124
127
|
if (err.code === 'EADDRINUSE') {
|
|
125
128
|
cliLogger.log('Port still in use, retrying...')
|
|
126
|
-
await killProcessOnPort(
|
|
129
|
+
await killProcessOnPort(lockPort)
|
|
127
130
|
await new Promise((r) => { setTimeout(r, 500) })
|
|
128
131
|
// Retry once
|
|
129
|
-
server.listen(
|
|
132
|
+
server.listen(lockPort, '127.0.0.1')
|
|
130
133
|
} else {
|
|
131
134
|
reject(err)
|
|
132
135
|
}
|
|
@@ -151,6 +154,7 @@ type Project = {
|
|
|
151
154
|
type CliOptions = {
|
|
152
155
|
restart?: boolean
|
|
153
156
|
addChannels?: boolean
|
|
157
|
+
dataDir?: string
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
// Commands to skip when registering user commands (reserved names)
|
|
@@ -644,7 +648,15 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
644
648
|
)
|
|
645
649
|
|
|
646
650
|
const availableProjects = deduplicateByKey(
|
|
647
|
-
projects.filter((project) =>
|
|
651
|
+
projects.filter((project) => {
|
|
652
|
+
if (existingDirs.includes(project.worktree)) {
|
|
653
|
+
return false
|
|
654
|
+
}
|
|
655
|
+
if (path.basename(project.worktree).startsWith('opencode-test-')) {
|
|
656
|
+
return false
|
|
657
|
+
}
|
|
658
|
+
return true
|
|
659
|
+
}),
|
|
648
660
|
(x) => x.worktree,
|
|
649
661
|
)
|
|
650
662
|
|
|
@@ -663,7 +675,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
663
675
|
message: 'Select projects to create Discord channels for:',
|
|
664
676
|
options: availableProjects.map((project) => ({
|
|
665
677
|
value: project.id,
|
|
666
|
-
label: `${path.basename(project.worktree)} (${project.worktree})`,
|
|
678
|
+
label: `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`,
|
|
667
679
|
})),
|
|
668
680
|
required: false,
|
|
669
681
|
})
|
|
@@ -823,13 +835,24 @@ cli
|
|
|
823
835
|
'--add-channels',
|
|
824
836
|
'Select OpenCode projects to create Discord channels before starting',
|
|
825
837
|
)
|
|
826
|
-
.
|
|
838
|
+
.option(
|
|
839
|
+
'--data-dir <path>',
|
|
840
|
+
'Data directory for config and database (default: ~/.kimaki)',
|
|
841
|
+
)
|
|
842
|
+
.action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string }) => {
|
|
827
843
|
try {
|
|
844
|
+
// Set data directory early, before any database access
|
|
845
|
+
if (options.dataDir) {
|
|
846
|
+
setDataDir(options.dataDir)
|
|
847
|
+
cliLogger.log(`Using data directory: ${getDataDir()}`)
|
|
848
|
+
}
|
|
849
|
+
|
|
828
850
|
await checkSingleInstance()
|
|
829
851
|
await startLockServer()
|
|
830
852
|
await run({
|
|
831
853
|
restart: options.restart,
|
|
832
854
|
addChannels: options.addChannels,
|
|
855
|
+
dataDir: options.dataDir,
|
|
833
856
|
})
|
|
834
857
|
} catch (error) {
|
|
835
858
|
cliLogger.error(
|
|
@@ -936,6 +959,196 @@ cli
|
|
|
936
959
|
})
|
|
937
960
|
|
|
938
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
|
+
|
|
939
1152
|
|
|
940
1153
|
cli.help()
|
|
941
1154
|
cli.parse()
|
|
@@ -7,6 +7,7 @@ import { getDatabase } from '../database.js'
|
|
|
7
7
|
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
8
8
|
import { createProjectChannels } from '../channel-management.js'
|
|
9
9
|
import { createLogger } from '../logger.js'
|
|
10
|
+
import { abbreviatePath } from '../utils.js'
|
|
10
11
|
|
|
11
12
|
const logger = createLogger('ADD-PROJECT')
|
|
12
13
|
|
|
@@ -107,9 +108,15 @@ export async function handleAddProjectAutocomplete({
|
|
|
107
108
|
.all('text') as { directory: string }[]
|
|
108
109
|
const existingDirSet = new Set(existingDirs.map((row) => row.directory))
|
|
109
110
|
|
|
110
|
-
const availableProjects = projectsResponse.data.filter(
|
|
111
|
-
(
|
|
112
|
-
|
|
111
|
+
const availableProjects = projectsResponse.data.filter((project) => {
|
|
112
|
+
if (existingDirSet.has(project.worktree)) {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
if (path.basename(project.worktree).startsWith('opencode-test-')) {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
return true
|
|
119
|
+
})
|
|
113
120
|
|
|
114
121
|
const projects = availableProjects
|
|
115
122
|
.filter((project) => {
|
|
@@ -124,7 +131,7 @@ export async function handleAddProjectAutocomplete({
|
|
|
124
131
|
})
|
|
125
132
|
.slice(0, 25)
|
|
126
133
|
.map((project) => {
|
|
127
|
-
const name = `${path.basename(project.worktree)} (${project.worktree})`
|
|
134
|
+
const name = `${path.basename(project.worktree)} (${abbreviatePath(project.worktree)})`
|
|
128
135
|
return {
|
|
129
136
|
name: name.length > 100 ? name.slice(0, 99) + '…' : name,
|
|
130
137
|
value: project.id,
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { ChannelType, type TextChannel } from 'discord.js'
|
|
4
4
|
import fs from 'node:fs'
|
|
5
|
-
import os from 'node:os'
|
|
6
5
|
import path from 'node:path'
|
|
7
6
|
import type { CommandContext } from './types.js'
|
|
7
|
+
import { getProjectsDir } from '../config.js'
|
|
8
8
|
import { createProjectChannels } from '../channel-management.js'
|
|
9
9
|
import { handleOpencodeSession } from '../session-handler.js'
|
|
10
10
|
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
@@ -44,13 +44,13 @@ export async function handleCreateNewProjectCommand({
|
|
|
44
44
|
return
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
const
|
|
48
|
-
const projectDirectory = path.join(
|
|
47
|
+
const projectsDir = getProjectsDir()
|
|
48
|
+
const projectDirectory = path.join(projectsDir, sanitizedName)
|
|
49
49
|
|
|
50
50
|
try {
|
|
51
|
-
if (!fs.existsSync(
|
|
52
|
-
fs.mkdirSync(
|
|
53
|
-
logger.log(`Created
|
|
51
|
+
if (!fs.existsSync(projectsDir)) {
|
|
52
|
+
fs.mkdirSync(projectsDir, { recursive: true })
|
|
53
|
+
logger.log(`Created projects directory: ${projectsDir}`)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
if (fs.existsSync(projectDirectory)) {
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Runtime configuration for Kimaki bot.
|
|
2
|
+
// Stores data directory path and provides accessors for other modules.
|
|
3
|
+
// Must be initialized before database or other path-dependent modules are used.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki')
|
|
10
|
+
|
|
11
|
+
let dataDir: string | null = null
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the data directory path.
|
|
15
|
+
* Falls back to ~/.kimaki if not explicitly set.
|
|
16
|
+
*/
|
|
17
|
+
export function getDataDir(): string {
|
|
18
|
+
if (!dataDir) {
|
|
19
|
+
dataDir = DEFAULT_DATA_DIR
|
|
20
|
+
}
|
|
21
|
+
return dataDir
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Set the data directory path.
|
|
26
|
+
* Creates the directory if it doesn't exist.
|
|
27
|
+
* Must be called before any database or path-dependent operations.
|
|
28
|
+
*/
|
|
29
|
+
export function setDataDir(dir: string): void {
|
|
30
|
+
const resolvedDir = path.resolve(dir)
|
|
31
|
+
|
|
32
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
33
|
+
fs.mkdirSync(resolvedDir, { recursive: true })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
dataDir = resolvedDir
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the projects directory path (for /create-new-project command).
|
|
41
|
+
* Returns <dataDir>/projects
|
|
42
|
+
*/
|
|
43
|
+
export function getProjectsDir(): string {
|
|
44
|
+
return path.join(getDataDir(), 'projects')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_LOCK_PORT = 29988
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Derive a lock port from the data directory path.
|
|
51
|
+
* Returns 29988 for the default ~/.kimaki directory (backwards compatible).
|
|
52
|
+
* For custom data dirs, uses a hash to generate a port in the range 30000-39999.
|
|
53
|
+
*/
|
|
54
|
+
export function getLockPort(): number {
|
|
55
|
+
const dir = getDataDir()
|
|
56
|
+
|
|
57
|
+
// Use original port for default data dir (backwards compatible)
|
|
58
|
+
if (dir === DEFAULT_DATA_DIR) {
|
|
59
|
+
return DEFAULT_LOCK_PORT
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Hash-based port for custom data dirs
|
|
63
|
+
let hash = 0
|
|
64
|
+
for (let i = 0; i < dir.length; i++) {
|
|
65
|
+
const char = dir.charCodeAt(i)
|
|
66
|
+
hash = ((hash << 5) - hash) + char
|
|
67
|
+
hash = hash & hash // Convert to 32bit integer
|
|
68
|
+
}
|
|
69
|
+
// Map to port range 30000-39999
|
|
70
|
+
return 30000 + (Math.abs(hash) % 10000)
|
|
71
|
+
}
|
package/src/database.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// SQLite database manager for persistent bot state.
|
|
2
2
|
// Stores thread-session mappings, bot tokens, channel directories,
|
|
3
|
-
// API keys, and model preferences in
|
|
3
|
+
// API keys, and model preferences in <dataDir>/discord-sessions.db.
|
|
4
4
|
|
|
5
5
|
import Database from 'better-sqlite3'
|
|
6
6
|
import fs from 'node:fs'
|
|
7
|
-
import os from 'node:os'
|
|
8
7
|
import path from 'node:path'
|
|
9
8
|
import { createLogger } from './logger.js'
|
|
9
|
+
import { getDataDir } from './config.js'
|
|
10
10
|
|
|
11
11
|
const dbLogger = createLogger('DB')
|
|
12
12
|
|
|
@@ -14,15 +14,15 @@ let db: Database.Database | null = null
|
|
|
14
14
|
|
|
15
15
|
export function getDatabase(): Database.Database {
|
|
16
16
|
if (!db) {
|
|
17
|
-
const
|
|
17
|
+
const dataDir = getDataDir()
|
|
18
18
|
|
|
19
19
|
try {
|
|
20
|
-
fs.mkdirSync(
|
|
20
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
21
21
|
} catch (error) {
|
|
22
|
-
dbLogger.error(
|
|
22
|
+
dbLogger.error(`Failed to create data directory ${dataDir}:`, error)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const dbPath = path.join(
|
|
25
|
+
const dbPath = path.join(dataDir, 'discord-sessions.db')
|
|
26
26
|
|
|
27
27
|
dbLogger.log(`Opening database at: ${dbPath}`)
|
|
28
28
|
db = new Database(dbPath)
|
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.
|
package/src/utils.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Includes Discord OAuth URL generation, array deduplication,
|
|
3
3
|
// abort error detection, and date/time formatting helpers.
|
|
4
4
|
|
|
5
|
+
import os from 'node:os'
|
|
5
6
|
import { PermissionsBitField } from 'discord.js'
|
|
6
7
|
|
|
7
8
|
type GenerateInstallUrlOptions = {
|
|
@@ -116,3 +117,11 @@ const dtf = new Intl.DateTimeFormat('en-US', {
|
|
|
116
117
|
export function formatDateTime(date: Date): string {
|
|
117
118
|
return dtf.format(date)
|
|
118
119
|
}
|
|
120
|
+
|
|
121
|
+
export function abbreviatePath(fullPath: string): string {
|
|
122
|
+
const home = os.homedir()
|
|
123
|
+
if (fullPath.startsWith(home)) {
|
|
124
|
+
return '~' + fullPath.slice(home.length)
|
|
125
|
+
}
|
|
126
|
+
return fullPath
|
|
127
|
+
}
|