kimaki 0.4.36 → 0.4.37
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/channel-management.js +4 -4
- package/dist/cli.js +57 -17
- package/dist/database.js +7 -0
- package/dist/discord-bot.js +33 -10
- package/dist/system-message.js +5 -1
- package/package.json +1 -1
- package/src/channel-management.ts +4 -4
- package/src/cli.ts +67 -25
- package/src/database.ts +7 -0
- package/src/discord-bot.ts +39 -13
- package/src/system-message.ts +5 -1
|
@@ -57,11 +57,11 @@ export async function createProjectChannels({ guild, projectDirectory, appId, bo
|
|
|
57
57
|
parent: kimakiAudioCategory,
|
|
58
58
|
});
|
|
59
59
|
getDatabase()
|
|
60
|
-
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
|
|
61
|
-
.run(textChannel.id, projectDirectory, 'text');
|
|
60
|
+
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
|
|
61
|
+
.run(textChannel.id, projectDirectory, 'text', appId);
|
|
62
62
|
getDatabase()
|
|
63
|
-
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)')
|
|
64
|
-
.run(voiceChannel.id, projectDirectory, 'voice');
|
|
63
|
+
.prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
|
|
64
|
+
.run(voiceChannel.id, projectDirectory, 'voice', appId);
|
|
65
65
|
return {
|
|
66
66
|
textChannelId: textChannel.id,
|
|
67
67
|
voiceChannelId: voiceChannel.id,
|
package/dist/cli.js
CHANGED
|
@@ -484,10 +484,10 @@ async function run({ restart, addChannels }) {
|
|
|
484
484
|
for (const { guild, channels } of kimakiChannels) {
|
|
485
485
|
for (const channel of channels) {
|
|
486
486
|
if (channel.kimakiDirectory) {
|
|
487
|
-
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text');
|
|
487
|
+
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null);
|
|
488
488
|
const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
|
|
489
489
|
if (voiceChannel) {
|
|
490
|
-
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice');
|
|
490
|
+
db.prepare('INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)').run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null);
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
}
|
|
@@ -749,17 +749,30 @@ cli
|
|
|
749
749
|
// Magic prefix used to identify bot-initiated sessions.
|
|
750
750
|
// The running bot will recognize this prefix and start a session.
|
|
751
751
|
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
|
|
752
|
+
// Notify-only prefix - bot won't start a session, just creates thread for notifications.
|
|
753
|
+
// Reply to the thread to start a session with the notification as context.
|
|
754
|
+
const BOT_NOTIFY_PREFIX = '📢 **Notification**';
|
|
752
755
|
cli
|
|
753
|
-
.command('
|
|
756
|
+
.command('send', 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.')
|
|
757
|
+
.alias('start-session') // backwards compatibility
|
|
754
758
|
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
755
759
|
.option('-d, --project <path>', 'Project directory (alternative to --channel)')
|
|
756
|
-
.option('-p, --prompt <prompt>', '
|
|
760
|
+
.option('-p, --prompt <prompt>', 'Message content')
|
|
757
761
|
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
758
762
|
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
763
|
+
.option('--notify-only', 'Create notification thread without starting AI session')
|
|
759
764
|
.action(async (options) => {
|
|
760
765
|
try {
|
|
761
|
-
let { channel: channelId, prompt, name, appId: optionAppId } = options;
|
|
766
|
+
let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options;
|
|
762
767
|
const { project: projectPath } = options;
|
|
768
|
+
// Get raw channel ID from argv to prevent JS number precision loss on large Discord IDs
|
|
769
|
+
// cac parses large numbers and loses precision, so we extract the original string value
|
|
770
|
+
if (channelId) {
|
|
771
|
+
const channelArgIndex = process.argv.findIndex((arg) => arg === '--channel' || arg === '-c');
|
|
772
|
+
if (channelArgIndex !== -1 && process.argv[channelArgIndex + 1]) {
|
|
773
|
+
channelId = process.argv[channelArgIndex + 1];
|
|
774
|
+
}
|
|
775
|
+
}
|
|
763
776
|
if (!channelId && !projectPath) {
|
|
764
777
|
cliLogger.error('Either --channel or --project is required');
|
|
765
778
|
process.exit(EXIT_NO_RESTART);
|
|
@@ -818,15 +831,38 @@ cli
|
|
|
818
831
|
process.exit(EXIT_NO_RESTART);
|
|
819
832
|
}
|
|
820
833
|
s.start('Looking up channel for project...');
|
|
821
|
-
// Check if channel already exists for this directory
|
|
834
|
+
// Check if channel already exists for this directory or a parent directory
|
|
835
|
+
// This allows running from subfolders of a registered project
|
|
822
836
|
try {
|
|
823
837
|
const db = getDatabase();
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
838
|
+
// Helper to find channel for a path (prefers current bot's channel)
|
|
839
|
+
const findChannelForPath = (dirPath) => {
|
|
840
|
+
const withAppId = db
|
|
841
|
+
.prepare('SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?')
|
|
842
|
+
.get(dirPath, 'text', appId);
|
|
843
|
+
if (withAppId)
|
|
844
|
+
return withAppId;
|
|
845
|
+
return db
|
|
846
|
+
.prepare('SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ?')
|
|
847
|
+
.get(dirPath, 'text');
|
|
848
|
+
};
|
|
849
|
+
// Try exact match first, then walk up parent directories
|
|
850
|
+
let existingChannel;
|
|
851
|
+
let searchPath = absolutePath;
|
|
852
|
+
while (searchPath !== path.dirname(searchPath)) {
|
|
853
|
+
existingChannel = findChannelForPath(searchPath);
|
|
854
|
+
if (existingChannel)
|
|
855
|
+
break;
|
|
856
|
+
searchPath = path.dirname(searchPath);
|
|
857
|
+
}
|
|
827
858
|
if (existingChannel) {
|
|
828
859
|
channelId = existingChannel.channel_id;
|
|
829
|
-
|
|
860
|
+
if (existingChannel.directory !== absolutePath) {
|
|
861
|
+
s.message(`Found parent project channel: ${existingChannel.directory}`);
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
s.message(`Found existing channel: ${channelId}`);
|
|
865
|
+
}
|
|
830
866
|
}
|
|
831
867
|
else {
|
|
832
868
|
// Need to create a new channel
|
|
@@ -846,10 +882,10 @@ cli
|
|
|
846
882
|
});
|
|
847
883
|
// Get guild from existing channels or first available
|
|
848
884
|
const guild = await (async () => {
|
|
849
|
-
// Try to find a guild from existing channels
|
|
885
|
+
// Try to find a guild from existing channels belonging to this bot
|
|
850
886
|
const existingChannelRow = db
|
|
851
|
-
.prepare('SELECT channel_id FROM channel_directories ORDER BY created_at DESC LIMIT 1')
|
|
852
|
-
.get();
|
|
887
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1')
|
|
888
|
+
.get(appId);
|
|
853
889
|
if (existingChannelRow) {
|
|
854
890
|
try {
|
|
855
891
|
const ch = await client.channels.fetch(existingChannelRow.channel_id);
|
|
@@ -861,7 +897,7 @@ cli
|
|
|
861
897
|
// Channel might be deleted, continue
|
|
862
898
|
}
|
|
863
899
|
}
|
|
864
|
-
// Fall back to first guild
|
|
900
|
+
// Fall back to first guild the bot is in
|
|
865
901
|
const firstGuild = client.guilds.cache.first();
|
|
866
902
|
if (!firstGuild) {
|
|
867
903
|
throw new Error('No guild found. Add the bot to a server first.');
|
|
@@ -918,7 +954,8 @@ cli
|
|
|
918
954
|
}
|
|
919
955
|
s.message('Creating starter message...');
|
|
920
956
|
// Create starter message with magic prefix
|
|
921
|
-
//
|
|
957
|
+
// BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
|
|
958
|
+
const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX;
|
|
922
959
|
const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
923
960
|
method: 'POST',
|
|
924
961
|
headers: {
|
|
@@ -926,7 +963,7 @@ cli
|
|
|
926
963
|
'Content-Type': 'application/json',
|
|
927
964
|
},
|
|
928
965
|
body: JSON.stringify({
|
|
929
|
-
content: `${
|
|
966
|
+
content: `${messagePrefix}\n${prompt}`,
|
|
930
967
|
}),
|
|
931
968
|
});
|
|
932
969
|
if (!starterMessageResponse.ok) {
|
|
@@ -957,7 +994,10 @@ cli
|
|
|
957
994
|
const threadData = (await threadResponse.json());
|
|
958
995
|
s.stop('Thread created!');
|
|
959
996
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
960
|
-
|
|
997
|
+
const successMessage = notifyOnly
|
|
998
|
+
? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
|
|
999
|
+
: `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`;
|
|
1000
|
+
note(successMessage, '✅ Thread Created');
|
|
961
1001
|
console.log(threadUrl);
|
|
962
1002
|
process.exit(0);
|
|
963
1003
|
}
|
package/dist/database.js
CHANGED
|
@@ -50,6 +50,13 @@ export function getDatabase() {
|
|
|
50
50
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
51
51
|
)
|
|
52
52
|
`);
|
|
53
|
+
// Migration: add app_id column to channel_directories for multi-bot support
|
|
54
|
+
try {
|
|
55
|
+
db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Column already exists, ignore
|
|
59
|
+
}
|
|
53
60
|
db.exec(`
|
|
54
61
|
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
55
62
|
app_id TEXT PRIMARY KEY,
|
package/dist/discord-bot.js
CHANGED
|
@@ -119,14 +119,6 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
119
119
|
if (isThread) {
|
|
120
120
|
const thread = channel;
|
|
121
121
|
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
|
|
122
|
-
const row = getDatabase()
|
|
123
|
-
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
124
|
-
.get(thread.id);
|
|
125
|
-
if (!row) {
|
|
126
|
-
discordLogger.log(`No session found for thread ${thread.id}`);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
|
|
130
122
|
const parent = thread.parent;
|
|
131
123
|
let projectDirectory;
|
|
132
124
|
let channelAppId;
|
|
@@ -150,6 +142,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
150
142
|
});
|
|
151
143
|
return;
|
|
152
144
|
}
|
|
145
|
+
const row = getDatabase()
|
|
146
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
147
|
+
.get(thread.id);
|
|
148
|
+
// No existing session - start a new one (e.g., replying to a notification thread)
|
|
149
|
+
if (!row) {
|
|
150
|
+
discordLogger.log(`No session for thread ${thread.id}, starting new session`);
|
|
151
|
+
if (!projectDirectory) {
|
|
152
|
+
discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Include starter message (notification) as context for the session
|
|
156
|
+
let prompt = message.content;
|
|
157
|
+
const starterMessage = await thread.fetchStarterMessage().catch(() => null);
|
|
158
|
+
if (starterMessage?.content) {
|
|
159
|
+
// Strip notification prefix if present
|
|
160
|
+
const notificationContent = starterMessage.content
|
|
161
|
+
.replace(/^📢 \*\*Notification\*\*\n?/, '')
|
|
162
|
+
.trim();
|
|
163
|
+
if (notificationContent) {
|
|
164
|
+
prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await handleOpencodeSession({
|
|
168
|
+
prompt,
|
|
169
|
+
thread,
|
|
170
|
+
projectDirectory,
|
|
171
|
+
channelId: parent?.id || '',
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`);
|
|
153
176
|
let messageContent = message.content || '';
|
|
154
177
|
let currentSessionContext;
|
|
155
178
|
let lastSessionContext;
|
|
@@ -293,9 +316,9 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
|
|
|
293
316
|
}
|
|
294
317
|
}
|
|
295
318
|
});
|
|
296
|
-
// Magic prefix used by `kimaki
|
|
319
|
+
// Magic prefix used by `kimaki send` CLI command to initiate sessions
|
|
297
320
|
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**';
|
|
298
|
-
// Handle bot-initiated threads created by `kimaki
|
|
321
|
+
// Handle bot-initiated threads created by `kimaki send`
|
|
299
322
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
300
323
|
try {
|
|
301
324
|
if (!newlyCreated) {
|
package/dist/system-message.js
CHANGED
|
@@ -28,7 +28,11 @@ ${channelId
|
|
|
28
28
|
|
|
29
29
|
To start a new thread/session in this channel programmatically, run:
|
|
30
30
|
|
|
31
|
-
npx -y kimaki
|
|
31
|
+
npx -y kimaki send --channel ${channelId} --prompt "your prompt here"
|
|
32
|
+
|
|
33
|
+
Use --notify-only to create a notification thread without starting an AI session:
|
|
34
|
+
|
|
35
|
+
npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
|
|
32
36
|
|
|
33
37
|
This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
|
|
34
38
|
`
|
package/package.json
CHANGED
|
@@ -90,15 +90,15 @@ export async function createProjectChannels({
|
|
|
90
90
|
|
|
91
91
|
getDatabase()
|
|
92
92
|
.prepare(
|
|
93
|
-
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
93
|
+
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
|
|
94
94
|
)
|
|
95
|
-
.run(textChannel.id, projectDirectory, 'text')
|
|
95
|
+
.run(textChannel.id, projectDirectory, 'text', appId)
|
|
96
96
|
|
|
97
97
|
getDatabase()
|
|
98
98
|
.prepare(
|
|
99
|
-
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
99
|
+
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
|
|
100
100
|
)
|
|
101
|
-
.run(voiceChannel.id, projectDirectory, 'voice')
|
|
101
|
+
.run(voiceChannel.id, projectDirectory, 'voice', appId)
|
|
102
102
|
|
|
103
103
|
return {
|
|
104
104
|
textChannelId: textChannel.id,
|
package/src/cli.ts
CHANGED
|
@@ -632,8 +632,8 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
632
632
|
for (const channel of channels) {
|
|
633
633
|
if (channel.kimakiDirectory) {
|
|
634
634
|
db.prepare(
|
|
635
|
-
'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
636
|
-
).run(channel.id, channel.kimakiDirectory, 'text')
|
|
635
|
+
'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
|
|
636
|
+
).run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null)
|
|
637
637
|
|
|
638
638
|
const voiceChannel = guild.channels.cache.find(
|
|
639
639
|
(ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name,
|
|
@@ -641,8 +641,8 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
641
641
|
|
|
642
642
|
if (voiceChannel) {
|
|
643
643
|
db.prepare(
|
|
644
|
-
'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
645
|
-
).run(voiceChannel.id, channel.kimakiDirectory, 'voice')
|
|
644
|
+
'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
|
|
645
|
+
).run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null)
|
|
646
646
|
}
|
|
647
647
|
}
|
|
648
648
|
}
|
|
@@ -1002,17 +1002,22 @@ cli
|
|
|
1002
1002
|
// Magic prefix used to identify bot-initiated sessions.
|
|
1003
1003
|
// The running bot will recognize this prefix and start a session.
|
|
1004
1004
|
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
|
|
1005
|
+
// Notify-only prefix - bot won't start a session, just creates thread for notifications.
|
|
1006
|
+
// Reply to the thread to start a session with the notification as context.
|
|
1007
|
+
const BOT_NOTIFY_PREFIX = '📢 **Notification**'
|
|
1005
1008
|
|
|
1006
1009
|
cli
|
|
1007
1010
|
.command(
|
|
1008
|
-
'
|
|
1009
|
-
'
|
|
1011
|
+
'send',
|
|
1012
|
+
'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.',
|
|
1010
1013
|
)
|
|
1014
|
+
.alias('start-session') // backwards compatibility
|
|
1011
1015
|
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
1012
1016
|
.option('-d, --project <path>', 'Project directory (alternative to --channel)')
|
|
1013
|
-
.option('-p, --prompt <prompt>', '
|
|
1017
|
+
.option('-p, --prompt <prompt>', 'Message content')
|
|
1014
1018
|
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
1015
1019
|
.option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
|
|
1020
|
+
.option('--notify-only', 'Create notification thread without starting AI session')
|
|
1016
1021
|
.action(
|
|
1017
1022
|
async (options: {
|
|
1018
1023
|
channel?: string
|
|
@@ -1020,10 +1025,20 @@ cli
|
|
|
1020
1025
|
prompt?: string
|
|
1021
1026
|
name?: string
|
|
1022
1027
|
appId?: string
|
|
1028
|
+
notifyOnly?: boolean
|
|
1023
1029
|
}) => {
|
|
1024
1030
|
try {
|
|
1025
|
-
let { channel: channelId, prompt, name, appId: optionAppId } = options
|
|
1031
|
+
let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options
|
|
1026
1032
|
const { project: projectPath } = options
|
|
1033
|
+
|
|
1034
|
+
// Get raw channel ID from argv to prevent JS number precision loss on large Discord IDs
|
|
1035
|
+
// cac parses large numbers and loses precision, so we extract the original string value
|
|
1036
|
+
if (channelId) {
|
|
1037
|
+
const channelArgIndex = process.argv.findIndex((arg) => arg === '--channel' || arg === '-c')
|
|
1038
|
+
if (channelArgIndex !== -1 && process.argv[channelArgIndex + 1]) {
|
|
1039
|
+
channelId = process.argv[channelArgIndex + 1]
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1027
1042
|
|
|
1028
1043
|
if (!channelId && !projectPath) {
|
|
1029
1044
|
cliLogger.error('Either --channel or --project is required')
|
|
@@ -1092,18 +1107,43 @@ cli
|
|
|
1092
1107
|
|
|
1093
1108
|
s.start('Looking up channel for project...')
|
|
1094
1109
|
|
|
1095
|
-
// Check if channel already exists for this directory
|
|
1110
|
+
// Check if channel already exists for this directory or a parent directory
|
|
1111
|
+
// This allows running from subfolders of a registered project
|
|
1096
1112
|
try {
|
|
1097
1113
|
const db = getDatabase()
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1114
|
+
|
|
1115
|
+
// Helper to find channel for a path (prefers current bot's channel)
|
|
1116
|
+
const findChannelForPath = (dirPath: string): { channel_id: string; directory: string } | undefined => {
|
|
1117
|
+
const withAppId = db
|
|
1118
|
+
.prepare(
|
|
1119
|
+
'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
|
|
1120
|
+
)
|
|
1121
|
+
.get(dirPath, 'text', appId) as { channel_id: string; directory: string } | undefined
|
|
1122
|
+
if (withAppId) return withAppId
|
|
1123
|
+
|
|
1124
|
+
return db
|
|
1125
|
+
.prepare(
|
|
1126
|
+
'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ?',
|
|
1127
|
+
)
|
|
1128
|
+
.get(dirPath, 'text') as { channel_id: string; directory: string } | undefined
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Try exact match first, then walk up parent directories
|
|
1132
|
+
let existingChannel: { channel_id: string; directory: string } | undefined
|
|
1133
|
+
let searchPath = absolutePath
|
|
1134
|
+
while (searchPath !== path.dirname(searchPath)) {
|
|
1135
|
+
existingChannel = findChannelForPath(searchPath)
|
|
1136
|
+
if (existingChannel) break
|
|
1137
|
+
searchPath = path.dirname(searchPath)
|
|
1138
|
+
}
|
|
1103
1139
|
|
|
1104
1140
|
if (existingChannel) {
|
|
1105
1141
|
channelId = existingChannel.channel_id
|
|
1106
|
-
|
|
1142
|
+
if (existingChannel.directory !== absolutePath) {
|
|
1143
|
+
s.message(`Found parent project channel: ${existingChannel.directory}`)
|
|
1144
|
+
} else {
|
|
1145
|
+
s.message(`Found existing channel: ${channelId}`)
|
|
1146
|
+
}
|
|
1107
1147
|
} else {
|
|
1108
1148
|
// Need to create a new channel
|
|
1109
1149
|
s.message('Creating new channel...')
|
|
@@ -1128,12 +1168,12 @@ cli
|
|
|
1128
1168
|
|
|
1129
1169
|
// Get guild from existing channels or first available
|
|
1130
1170
|
const guild = await (async () => {
|
|
1131
|
-
// Try to find a guild from existing channels
|
|
1171
|
+
// Try to find a guild from existing channels belonging to this bot
|
|
1132
1172
|
const existingChannelRow = db
|
|
1133
1173
|
.prepare(
|
|
1134
|
-
'SELECT channel_id FROM channel_directories ORDER BY created_at DESC LIMIT 1',
|
|
1174
|
+
'SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1',
|
|
1135
1175
|
)
|
|
1136
|
-
.get() as { channel_id: string } | undefined
|
|
1176
|
+
.get(appId) as { channel_id: string } | undefined
|
|
1137
1177
|
|
|
1138
1178
|
if (existingChannelRow) {
|
|
1139
1179
|
try {
|
|
@@ -1145,7 +1185,7 @@ cli
|
|
|
1145
1185
|
// Channel might be deleted, continue
|
|
1146
1186
|
}
|
|
1147
1187
|
}
|
|
1148
|
-
// Fall back to first guild
|
|
1188
|
+
// Fall back to first guild the bot is in
|
|
1149
1189
|
const firstGuild = client.guilds.cache.first()
|
|
1150
1190
|
if (!firstGuild) {
|
|
1151
1191
|
throw new Error('No guild found. Add the bot to a server first.')
|
|
@@ -1224,7 +1264,8 @@ cli
|
|
|
1224
1264
|
s.message('Creating starter message...')
|
|
1225
1265
|
|
|
1226
1266
|
// Create starter message with magic prefix
|
|
1227
|
-
//
|
|
1267
|
+
// BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
|
|
1268
|
+
const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX
|
|
1228
1269
|
const starterMessageResponse = await fetch(
|
|
1229
1270
|
`https://discord.com/api/v10/channels/${channelId}/messages`,
|
|
1230
1271
|
{
|
|
@@ -1234,7 +1275,7 @@ cli
|
|
|
1234
1275
|
'Content-Type': 'application/json',
|
|
1235
1276
|
},
|
|
1236
1277
|
body: JSON.stringify({
|
|
1237
|
-
content: `${
|
|
1278
|
+
content: `${messagePrefix}\n${prompt}`,
|
|
1238
1279
|
}),
|
|
1239
1280
|
},
|
|
1240
1281
|
)
|
|
@@ -1278,10 +1319,11 @@ cli
|
|
|
1278
1319
|
|
|
1279
1320
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
|
|
1280
1321
|
|
|
1281
|
-
|
|
1282
|
-
`Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\
|
|
1283
|
-
|
|
1284
|
-
|
|
1322
|
+
const successMessage = notifyOnly
|
|
1323
|
+
? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
|
|
1324
|
+
: `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`
|
|
1325
|
+
|
|
1326
|
+
note(successMessage, '✅ Thread Created')
|
|
1285
1327
|
|
|
1286
1328
|
console.log(threadUrl)
|
|
1287
1329
|
|
package/src/database.ts
CHANGED
|
@@ -61,6 +61,13 @@ export function getDatabase(): Database.Database {
|
|
|
61
61
|
)
|
|
62
62
|
`)
|
|
63
63
|
|
|
64
|
+
// Migration: add app_id column to channel_directories for multi-bot support
|
|
65
|
+
try {
|
|
66
|
+
db.exec(`ALTER TABLE channel_directories ADD COLUMN app_id TEXT`)
|
|
67
|
+
} catch {
|
|
68
|
+
// Column already exists, ignore
|
|
69
|
+
}
|
|
70
|
+
|
|
64
71
|
db.exec(`
|
|
65
72
|
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
66
73
|
app_id TEXT PRIMARY KEY,
|
package/src/discord-bot.ts
CHANGED
|
@@ -187,17 +187,6 @@ export async function startDiscordBot({
|
|
|
187
187
|
const thread = channel as ThreadChannel
|
|
188
188
|
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
|
|
189
189
|
|
|
190
|
-
const row = getDatabase()
|
|
191
|
-
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
192
|
-
.get(thread.id) as { session_id: string } | undefined
|
|
193
|
-
|
|
194
|
-
if (!row) {
|
|
195
|
-
discordLogger.log(`No session found for thread ${thread.id}`)
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`)
|
|
200
|
-
|
|
201
190
|
const parent = thread.parent as TextChannel | null
|
|
202
191
|
let projectDirectory: string | undefined
|
|
203
192
|
let channelAppId: string | undefined
|
|
@@ -228,6 +217,43 @@ export async function startDiscordBot({
|
|
|
228
217
|
return
|
|
229
218
|
}
|
|
230
219
|
|
|
220
|
+
const row = getDatabase()
|
|
221
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
222
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
223
|
+
|
|
224
|
+
// No existing session - start a new one (e.g., replying to a notification thread)
|
|
225
|
+
if (!row) {
|
|
226
|
+
discordLogger.log(`No session for thread ${thread.id}, starting new session`)
|
|
227
|
+
|
|
228
|
+
if (!projectDirectory) {
|
|
229
|
+
discordLogger.log(`Cannot start session: no project directory for thread ${thread.id}`)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Include starter message (notification) as context for the session
|
|
234
|
+
let prompt = message.content
|
|
235
|
+
const starterMessage = await thread.fetchStarterMessage().catch(() => null)
|
|
236
|
+
if (starterMessage?.content) {
|
|
237
|
+
// Strip notification prefix if present
|
|
238
|
+
const notificationContent = starterMessage.content
|
|
239
|
+
.replace(/^📢 \*\*Notification\*\*\n?/, '')
|
|
240
|
+
.trim()
|
|
241
|
+
if (notificationContent) {
|
|
242
|
+
prompt = `Context from notification:\n${notificationContent}\n\nUser request:\n${message.content}`
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await handleOpencodeSession({
|
|
247
|
+
prompt,
|
|
248
|
+
thread,
|
|
249
|
+
projectDirectory,
|
|
250
|
+
channelId: parent?.id || '',
|
|
251
|
+
})
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
voiceLogger.log(`[SESSION] Found session ${row.session_id} for thread ${thread.id}`)
|
|
256
|
+
|
|
231
257
|
let messageContent = message.content || ''
|
|
232
258
|
|
|
233
259
|
let currentSessionContext: string | undefined
|
|
@@ -393,10 +419,10 @@ export async function startDiscordBot({
|
|
|
393
419
|
}
|
|
394
420
|
})
|
|
395
421
|
|
|
396
|
-
// Magic prefix used by `kimaki
|
|
422
|
+
// Magic prefix used by `kimaki send` CLI command to initiate sessions
|
|
397
423
|
const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
|
|
398
424
|
|
|
399
|
-
// Handle bot-initiated threads created by `kimaki
|
|
425
|
+
// Handle bot-initiated threads created by `kimaki send`
|
|
400
426
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
401
427
|
try {
|
|
402
428
|
if (!newlyCreated) {
|
package/src/system-message.ts
CHANGED
|
@@ -36,7 +36,11 @@ ${
|
|
|
36
36
|
|
|
37
37
|
To start a new thread/session in this channel programmatically, run:
|
|
38
38
|
|
|
39
|
-
npx -y kimaki
|
|
39
|
+
npx -y kimaki send --channel ${channelId} --prompt "your prompt here"
|
|
40
|
+
|
|
41
|
+
Use --notify-only to create a notification thread without starting an AI session:
|
|
42
|
+
|
|
43
|
+
npx -y kimaki send --channel ${channelId} --prompt "User cancelled subscription" --notify-only
|
|
40
44
|
|
|
41
45
|
This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
|
|
42
46
|
`
|