kimaki 0.4.35 → 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/ai-tool-to-genai.js +1 -3
- package/dist/channel-management.js +5 -5
- package/dist/cli.js +182 -46
- package/dist/commands/abort.js +1 -1
- package/dist/commands/add-project.js +1 -1
- package/dist/commands/agent.js +6 -2
- package/dist/commands/ask-question.js +2 -1
- package/dist/commands/fork.js +7 -7
- package/dist/commands/queue.js +2 -2
- package/dist/commands/remove-project.js +109 -0
- package/dist/commands/resume.js +3 -5
- package/dist/commands/session.js +2 -2
- package/dist/commands/share.js +1 -1
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/user-command.js +3 -6
- package/dist/config.js +1 -1
- package/dist/database.js +7 -0
- package/dist/discord-bot.js +37 -20
- package/dist/discord-utils.js +33 -9
- package/dist/genai.js +4 -6
- package/dist/interaction-handler.js +8 -1
- package/dist/markdown.js +1 -3
- package/dist/message-formatting.js +7 -3
- package/dist/openai-realtime.js +3 -5
- package/dist/opencode.js +1 -1
- package/dist/session-handler.js +25 -15
- package/dist/system-message.js +10 -4
- package/dist/tools.js +9 -22
- package/dist/voice-handler.js +9 -12
- package/dist/voice.js +5 -3
- package/dist/xml.js +2 -4
- package/package.json +3 -2
- package/src/__snapshots__/compact-session-context-no-system.md +24 -24
- package/src/__snapshots__/compact-session-context.md +31 -31
- package/src/ai-tool-to-genai.ts +3 -11
- package/src/channel-management.ts +18 -29
- package/src/cli.ts +334 -205
- package/src/commands/abort.ts +1 -3
- package/src/commands/add-project.ts +8 -14
- package/src/commands/agent.ts +16 -9
- package/src/commands/ask-question.ts +8 -7
- package/src/commands/create-new-project.ts +8 -14
- package/src/commands/fork.ts +23 -27
- package/src/commands/model.ts +14 -11
- package/src/commands/permissions.ts +1 -1
- package/src/commands/queue.ts +6 -19
- package/src/commands/remove-project.ts +136 -0
- package/src/commands/resume.ts +11 -30
- package/src/commands/session.ts +4 -13
- package/src/commands/share.ts +1 -3
- package/src/commands/types.ts +1 -3
- package/src/commands/undo-redo.ts +6 -18
- package/src/commands/user-command.ts +8 -10
- package/src/config.ts +5 -5
- package/src/database.ts +17 -8
- package/src/discord-bot.ts +60 -58
- package/src/discord-utils.ts +35 -18
- package/src/escape-backticks.test.ts +0 -2
- package/src/format-tables.ts +1 -4
- package/src/genai-worker-wrapper.ts +3 -9
- package/src/genai-worker.ts +4 -19
- package/src/genai.ts +10 -42
- package/src/interaction-handler.ts +133 -121
- package/src/markdown.test.ts +10 -32
- package/src/markdown.ts +6 -14
- package/src/message-formatting.ts +13 -14
- package/src/openai-realtime.ts +25 -47
- package/src/opencode.ts +24 -34
- package/src/session-handler.ts +91 -61
- package/src/system-message.ts +18 -4
- package/src/tools.ts +13 -39
- package/src/utils.ts +1 -4
- package/src/voice-handler.ts +34 -78
- package/src/voice.ts +11 -19
- package/src/xml.test.ts +1 -1
- package/src/xml.ts +3 -12
package/dist/ai-tool-to-genai.js
CHANGED
|
@@ -150,9 +150,7 @@ export function aiToolToCallableTool(tool, name) {
|
|
|
150
150
|
const parts = [];
|
|
151
151
|
for (const functionCall of functionCalls) {
|
|
152
152
|
// Check if this function call matches our tool
|
|
153
|
-
if (functionCall.name !== toolName &&
|
|
154
|
-
name &&
|
|
155
|
-
functionCall.name !== name) {
|
|
153
|
+
if (functionCall.name !== toolName && name && functionCall.name !== name) {
|
|
156
154
|
continue;
|
|
157
155
|
}
|
|
158
156
|
// Execute the tool if it has an execute function
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Discord channel and category management.
|
|
2
2
|
// Creates and manages Kimaki project channels (text + voice pairs),
|
|
3
3
|
// extracts channel metadata from topic tags, and ensures category structure.
|
|
4
|
-
import { ChannelType
|
|
4
|
+
import { ChannelType } from 'discord.js';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { getDatabase } from './database.js';
|
|
7
7
|
import { extractTagsArrays } from './xml.js';
|
|
@@ -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
|
@@ -23,11 +23,18 @@ async function killProcessOnPort(port) {
|
|
|
23
23
|
try {
|
|
24
24
|
if (isWindows) {
|
|
25
25
|
// Windows: find PID using netstat, then kill
|
|
26
|
-
const result = spawnSync('cmd', [
|
|
26
|
+
const result = spawnSync('cmd', [
|
|
27
|
+
'/c',
|
|
28
|
+
`for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`,
|
|
29
|
+
], {
|
|
27
30
|
shell: false,
|
|
28
31
|
encoding: 'utf-8',
|
|
29
32
|
});
|
|
30
|
-
const pids = result.stdout
|
|
33
|
+
const pids = result.stdout
|
|
34
|
+
?.trim()
|
|
35
|
+
.split('\n')
|
|
36
|
+
.map((p) => p.trim())
|
|
37
|
+
.filter((p) => /^\d+$/.test(p));
|
|
31
38
|
// Filter out our own PID and take the first (oldest)
|
|
32
39
|
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
|
|
33
40
|
if (targetPid) {
|
|
@@ -42,7 +49,11 @@ async function killProcessOnPort(port) {
|
|
|
42
49
|
shell: false,
|
|
43
50
|
encoding: 'utf-8',
|
|
44
51
|
});
|
|
45
|
-
const pids = result.stdout
|
|
52
|
+
const pids = result.stdout
|
|
53
|
+
?.trim()
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((p) => p.trim())
|
|
56
|
+
.filter((p) => /^\d+$/.test(p));
|
|
46
57
|
// Filter out our own PID and take the first (oldest)
|
|
47
58
|
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
|
|
48
59
|
if (targetPid) {
|
|
@@ -68,7 +79,9 @@ async function checkSingleInstance() {
|
|
|
68
79
|
cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`);
|
|
69
80
|
await killProcessOnPort(lockPort);
|
|
70
81
|
// Wait a moment for port to be released
|
|
71
|
-
await new Promise((resolve) => {
|
|
82
|
+
await new Promise((resolve) => {
|
|
83
|
+
setTimeout(resolve, 500);
|
|
84
|
+
});
|
|
72
85
|
}
|
|
73
86
|
}
|
|
74
87
|
catch {
|
|
@@ -91,7 +104,9 @@ async function startLockServer() {
|
|
|
91
104
|
if (err.code === 'EADDRINUSE') {
|
|
92
105
|
cliLogger.log('Port still in use, retrying...');
|
|
93
106
|
await killProcessOnPort(lockPort);
|
|
94
|
-
await new Promise((r) => {
|
|
107
|
+
await new Promise((r) => {
|
|
108
|
+
setTimeout(r, 500);
|
|
109
|
+
});
|
|
95
110
|
// Retry once
|
|
96
111
|
server.listen(lockPort, '127.0.0.1');
|
|
97
112
|
}
|
|
@@ -122,10 +137,7 @@ async function registerCommands(token, appId, userCommands = []) {
|
|
|
122
137
|
.setName('session')
|
|
123
138
|
.setDescription('Start a new OpenCode session')
|
|
124
139
|
.addStringOption((option) => {
|
|
125
|
-
option
|
|
126
|
-
.setName('prompt')
|
|
127
|
-
.setDescription('Prompt content for the session')
|
|
128
|
-
.setRequired(true);
|
|
140
|
+
option.setName('prompt').setDescription('Prompt content for the session').setRequired(true);
|
|
129
141
|
return option;
|
|
130
142
|
})
|
|
131
143
|
.addStringOption((option) => {
|
|
@@ -156,14 +168,23 @@ async function registerCommands(token, appId, userCommands = []) {
|
|
|
156
168
|
return option;
|
|
157
169
|
})
|
|
158
170
|
.toJSON(),
|
|
171
|
+
new SlashCommandBuilder()
|
|
172
|
+
.setName('remove-project')
|
|
173
|
+
.setDescription('Remove Discord channels for a project')
|
|
174
|
+
.addStringOption((option) => {
|
|
175
|
+
option
|
|
176
|
+
.setName('project')
|
|
177
|
+
.setDescription('Select a project to remove')
|
|
178
|
+
.setRequired(true)
|
|
179
|
+
.setAutocomplete(true);
|
|
180
|
+
return option;
|
|
181
|
+
})
|
|
182
|
+
.toJSON(),
|
|
159
183
|
new SlashCommandBuilder()
|
|
160
184
|
.setName('create-new-project')
|
|
161
185
|
.setDescription('Create a new project folder, initialize git, and start a session')
|
|
162
186
|
.addStringOption((option) => {
|
|
163
|
-
option
|
|
164
|
-
.setName('name')
|
|
165
|
-
.setDescription('Name for the new project folder')
|
|
166
|
-
.setRequired(true);
|
|
187
|
+
option.setName('name').setDescription('Name for the new project folder').setRequired(true);
|
|
167
188
|
return option;
|
|
168
189
|
})
|
|
169
190
|
.toJSON(),
|
|
@@ -195,10 +216,7 @@ async function registerCommands(token, appId, userCommands = []) {
|
|
|
195
216
|
.setName('queue')
|
|
196
217
|
.setDescription('Queue a message to be sent after the current response finishes')
|
|
197
218
|
.addStringOption((option) => {
|
|
198
|
-
option
|
|
199
|
-
.setName('message')
|
|
200
|
-
.setDescription('The message to queue')
|
|
201
|
-
.setRequired(true);
|
|
219
|
+
option.setName('message').setDescription('The message to queue').setRequired(true);
|
|
202
220
|
return option;
|
|
203
221
|
})
|
|
204
222
|
.toJSON(),
|
|
@@ -225,7 +243,7 @@ async function registerCommands(token, appId, userCommands = []) {
|
|
|
225
243
|
const commandName = `${sanitizedName}-cmd`;
|
|
226
244
|
const description = cmd.description || `Run /${cmd.name} command`;
|
|
227
245
|
commands.push(new SlashCommandBuilder()
|
|
228
|
-
.setName(commandName)
|
|
246
|
+
.setName(commandName.slice(0, 32)) // Discord limits to 32 chars
|
|
229
247
|
.setDescription(description.slice(0, 100)) // Discord limits to 100 chars
|
|
230
248
|
.addStringOption((option) => {
|
|
231
249
|
option
|
|
@@ -466,10 +484,10 @@ async function run({ restart, addChannels }) {
|
|
|
466
484
|
for (const { guild, channels } of kimakiChannels) {
|
|
467
485
|
for (const channel of channels) {
|
|
468
486
|
if (channel.kimakiDirectory) {
|
|
469
|
-
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);
|
|
470
488
|
const voiceChannel = guild.channels.cache.find((ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name);
|
|
471
489
|
if (voiceChannel) {
|
|
472
|
-
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);
|
|
473
491
|
}
|
|
474
492
|
}
|
|
475
493
|
}
|
|
@@ -477,11 +495,7 @@ async function run({ restart, addChannels }) {
|
|
|
477
495
|
if (kimakiChannels.length > 0) {
|
|
478
496
|
const channelList = kimakiChannels
|
|
479
497
|
.flatMap(({ guild, channels }) => channels.map((ch) => {
|
|
480
|
-
const appInfo = ch.kimakiApp === appId
|
|
481
|
-
? ' (this bot)'
|
|
482
|
-
: ch.kimakiApp
|
|
483
|
-
? ` (app: ${ch.kimakiApp})`
|
|
484
|
-
: '';
|
|
498
|
+
const appInfo = ch.kimakiApp === appId ? ' (this bot)' : ch.kimakiApp ? ` (app: ${ch.kimakiApp})` : '';
|
|
485
499
|
return `#${ch.name} in ${guild.name}: ${ch.kimakiDirectory}${appInfo}`;
|
|
486
500
|
}))
|
|
487
501
|
.join('\n');
|
|
@@ -494,13 +508,19 @@ async function run({ restart, addChannels }) {
|
|
|
494
508
|
s.start('Fetching OpenCode data...');
|
|
495
509
|
// Fetch projects and commands in parallel
|
|
496
510
|
const [projects, allUserCommands] = await Promise.all([
|
|
497
|
-
getClient()
|
|
511
|
+
getClient()
|
|
512
|
+
.project.list({})
|
|
513
|
+
.then((r) => r.data || [])
|
|
514
|
+
.catch((error) => {
|
|
498
515
|
s.stop('Failed to fetch projects');
|
|
499
516
|
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
500
517
|
discordClient.destroy();
|
|
501
518
|
process.exit(EXIT_NO_RESTART);
|
|
502
519
|
}),
|
|
503
|
-
getClient()
|
|
520
|
+
getClient()
|
|
521
|
+
.command.list({ query: { directory: currentDir } })
|
|
522
|
+
.then((r) => r.data || [])
|
|
523
|
+
.catch(() => []),
|
|
504
524
|
]);
|
|
505
525
|
s.stop(`Found ${projects.length} OpenCode project(s)`);
|
|
506
526
|
const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
|
|
@@ -519,8 +539,7 @@ async function run({ restart, addChannels }) {
|
|
|
519
539
|
if (availableProjects.length === 0) {
|
|
520
540
|
note('All OpenCode projects already have Discord channels', 'No New Projects');
|
|
521
541
|
}
|
|
522
|
-
if ((!existingDirs?.length && availableProjects.length > 0) ||
|
|
523
|
-
shouldAddChannels) {
|
|
542
|
+
if ((!existingDirs?.length && availableProjects.length > 0) || shouldAddChannels) {
|
|
524
543
|
const selectedProjects = await multiselect({
|
|
525
544
|
message: 'Select projects to create Discord channels for:',
|
|
526
545
|
options: availableProjects.map((project) => ({
|
|
@@ -620,7 +639,8 @@ async function run({ restart, addChannels }) {
|
|
|
620
639
|
.join('\n');
|
|
621
640
|
note(`Your kimaki channels are ready! Click any link below to open in Discord:\n\n${channelLinks}\n\nSend a message in any channel to start using OpenCode!`, '🚀 Ready to Use');
|
|
622
641
|
}
|
|
623
|
-
|
|
642
|
+
note('Leave this process running to keep the bot active.\n\nIf you close this process or restart your machine, run `npx kimaki` again to start the bot.', '⚠️ Keep Running');
|
|
643
|
+
outro('✨ Setup complete! Listening for new messages... do not close this process.');
|
|
624
644
|
}
|
|
625
645
|
cli
|
|
626
646
|
.command('', 'Set up and run the Kimaki Discord bot')
|
|
@@ -702,13 +722,13 @@ cli
|
|
|
702
722
|
const buffer = fs.readFileSync(file);
|
|
703
723
|
const formData = new FormData();
|
|
704
724
|
formData.append('payload_json', JSON.stringify({
|
|
705
|
-
attachments: [{ id: 0, filename: path.basename(file) }]
|
|
725
|
+
attachments: [{ id: 0, filename: path.basename(file) }],
|
|
706
726
|
}));
|
|
707
727
|
formData.append('files[0]', new Blob([buffer]), path.basename(file));
|
|
708
728
|
const response = await fetch(`https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`, {
|
|
709
729
|
method: 'POST',
|
|
710
730
|
headers: {
|
|
711
|
-
|
|
731
|
+
Authorization: `Bot ${botRow.token}`,
|
|
712
732
|
},
|
|
713
733
|
body: formData,
|
|
714
734
|
});
|
|
@@ -729,17 +749,32 @@ cli
|
|
|
729
749
|
// Magic prefix used to identify bot-initiated sessions.
|
|
730
750
|
// The running bot will recognize this prefix and start a session.
|
|
731
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**';
|
|
732
755
|
cli
|
|
733
|
-
.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
|
|
734
758
|
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
735
|
-
.option('-
|
|
759
|
+
.option('-d, --project <path>', 'Project directory (alternative to --channel)')
|
|
760
|
+
.option('-p, --prompt <prompt>', 'Message content')
|
|
736
761
|
.option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
|
|
737
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')
|
|
738
764
|
.action(async (options) => {
|
|
739
765
|
try {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
766
|
+
let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options;
|
|
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
|
+
}
|
|
776
|
+
if (!channelId && !projectPath) {
|
|
777
|
+
cliLogger.error('Either --channel or --project is required');
|
|
743
778
|
process.exit(EXIT_NO_RESTART);
|
|
744
779
|
}
|
|
745
780
|
if (!prompt) {
|
|
@@ -788,11 +823,108 @@ cli
|
|
|
788
823
|
process.exit(EXIT_NO_RESTART);
|
|
789
824
|
}
|
|
790
825
|
const s = spinner();
|
|
826
|
+
// If --project provided, resolve to channel ID
|
|
827
|
+
if (projectPath) {
|
|
828
|
+
const absolutePath = path.resolve(projectPath);
|
|
829
|
+
if (!fs.existsSync(absolutePath)) {
|
|
830
|
+
cliLogger.error(`Directory does not exist: ${absolutePath}`);
|
|
831
|
+
process.exit(EXIT_NO_RESTART);
|
|
832
|
+
}
|
|
833
|
+
s.start('Looking up channel for project...');
|
|
834
|
+
// Check if channel already exists for this directory or a parent directory
|
|
835
|
+
// This allows running from subfolders of a registered project
|
|
836
|
+
try {
|
|
837
|
+
const db = getDatabase();
|
|
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
|
+
}
|
|
858
|
+
if (existingChannel) {
|
|
859
|
+
channelId = existingChannel.channel_id;
|
|
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
|
+
}
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
// Need to create a new channel
|
|
869
|
+
s.message('Creating new channel...');
|
|
870
|
+
if (!appId) {
|
|
871
|
+
s.stop('Missing app ID');
|
|
872
|
+
cliLogger.error('App ID is required to create channels. Use --app-id or run `kimaki` first.');
|
|
873
|
+
process.exit(EXIT_NO_RESTART);
|
|
874
|
+
}
|
|
875
|
+
const client = await createDiscordClient();
|
|
876
|
+
await new Promise((resolve, reject) => {
|
|
877
|
+
client.once(Events.ClientReady, () => {
|
|
878
|
+
resolve();
|
|
879
|
+
});
|
|
880
|
+
client.once(Events.Error, reject);
|
|
881
|
+
client.login(botToken);
|
|
882
|
+
});
|
|
883
|
+
// Get guild from existing channels or first available
|
|
884
|
+
const guild = await (async () => {
|
|
885
|
+
// Try to find a guild from existing channels belonging to this bot
|
|
886
|
+
const existingChannelRow = db
|
|
887
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1')
|
|
888
|
+
.get(appId);
|
|
889
|
+
if (existingChannelRow) {
|
|
890
|
+
try {
|
|
891
|
+
const ch = await client.channels.fetch(existingChannelRow.channel_id);
|
|
892
|
+
if (ch && 'guild' in ch && ch.guild) {
|
|
893
|
+
return ch.guild;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
// Channel might be deleted, continue
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Fall back to first guild the bot is in
|
|
901
|
+
const firstGuild = client.guilds.cache.first();
|
|
902
|
+
if (!firstGuild) {
|
|
903
|
+
throw new Error('No guild found. Add the bot to a server first.');
|
|
904
|
+
}
|
|
905
|
+
return firstGuild;
|
|
906
|
+
})();
|
|
907
|
+
const { textChannelId } = await createProjectChannels({
|
|
908
|
+
guild,
|
|
909
|
+
projectDirectory: absolutePath,
|
|
910
|
+
appId,
|
|
911
|
+
botName: client.user?.username,
|
|
912
|
+
});
|
|
913
|
+
channelId = textChannelId;
|
|
914
|
+
s.message(`Created channel: ${channelId}`);
|
|
915
|
+
client.destroy();
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
catch (e) {
|
|
919
|
+
s.stop('Failed to resolve project');
|
|
920
|
+
throw e;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
791
923
|
s.start('Fetching channel info...');
|
|
792
924
|
// Get channel info to extract directory from topic
|
|
793
925
|
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
|
794
926
|
headers: {
|
|
795
|
-
|
|
927
|
+
Authorization: `Bot ${botToken}`,
|
|
796
928
|
},
|
|
797
929
|
});
|
|
798
930
|
if (!channelResponse.ok) {
|
|
@@ -800,7 +932,7 @@ cli
|
|
|
800
932
|
s.stop('Failed to fetch channel');
|
|
801
933
|
throw new Error(`Discord API error: ${channelResponse.status} - ${error}`);
|
|
802
934
|
}
|
|
803
|
-
const channelData = await channelResponse.json();
|
|
935
|
+
const channelData = (await channelResponse.json());
|
|
804
936
|
if (!channelData.topic) {
|
|
805
937
|
s.stop('Channel has no topic');
|
|
806
938
|
throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`);
|
|
@@ -822,15 +954,16 @@ cli
|
|
|
822
954
|
}
|
|
823
955
|
s.message('Creating starter message...');
|
|
824
956
|
// Create starter message with magic prefix
|
|
825
|
-
//
|
|
957
|
+
// BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
|
|
958
|
+
const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX;
|
|
826
959
|
const starterMessageResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
|
|
827
960
|
method: 'POST',
|
|
828
961
|
headers: {
|
|
829
|
-
|
|
962
|
+
Authorization: `Bot ${botToken}`,
|
|
830
963
|
'Content-Type': 'application/json',
|
|
831
964
|
},
|
|
832
965
|
body: JSON.stringify({
|
|
833
|
-
content: `${
|
|
966
|
+
content: `${messagePrefix}\n${prompt}`,
|
|
834
967
|
}),
|
|
835
968
|
});
|
|
836
969
|
if (!starterMessageResponse.ok) {
|
|
@@ -838,14 +971,14 @@ cli
|
|
|
838
971
|
s.stop('Failed to create message');
|
|
839
972
|
throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`);
|
|
840
973
|
}
|
|
841
|
-
const starterMessage = await starterMessageResponse.json();
|
|
974
|
+
const starterMessage = (await starterMessageResponse.json());
|
|
842
975
|
s.message('Creating thread...');
|
|
843
976
|
// Create thread from the message
|
|
844
977
|
const threadName = name || (prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt);
|
|
845
978
|
const threadResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${starterMessage.id}/threads`, {
|
|
846
979
|
method: 'POST',
|
|
847
980
|
headers: {
|
|
848
|
-
|
|
981
|
+
Authorization: `Bot ${botToken}`,
|
|
849
982
|
'Content-Type': 'application/json',
|
|
850
983
|
},
|
|
851
984
|
body: JSON.stringify({
|
|
@@ -858,10 +991,13 @@ cli
|
|
|
858
991
|
s.stop('Failed to create thread');
|
|
859
992
|
throw new Error(`Discord API error: ${threadResponse.status} - ${error}`);
|
|
860
993
|
}
|
|
861
|
-
const threadData = await threadResponse.json();
|
|
994
|
+
const threadData = (await threadResponse.json());
|
|
862
995
|
s.stop('Thread created!');
|
|
863
996
|
const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`;
|
|
864
|
-
|
|
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');
|
|
865
1001
|
console.log(threadUrl);
|
|
866
1002
|
process.exit(0);
|
|
867
1003
|
}
|
package/dist/commands/abort.js
CHANGED
|
@@ -6,7 +6,7 @@ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../
|
|
|
6
6
|
import { abortControllers } from '../session-handler.js';
|
|
7
7
|
import { createLogger } from '../logger.js';
|
|
8
8
|
const logger = createLogger('ABORT');
|
|
9
|
-
export async function handleAbortCommand({ command
|
|
9
|
+
export async function handleAbortCommand({ command }) {
|
|
10
10
|
const channel = command.channel;
|
|
11
11
|
if (!channel) {
|
|
12
12
|
await command.reply({
|
|
@@ -7,7 +7,7 @@ import { createProjectChannels } from '../channel-management.js';
|
|
|
7
7
|
import { createLogger } from '../logger.js';
|
|
8
8
|
import { abbreviatePath } from '../utils.js';
|
|
9
9
|
const logger = createLogger('ADD-PROJECT');
|
|
10
|
-
export async function handleAddProjectCommand({ command, appId
|
|
10
|
+
export async function handleAddProjectCommand({ command, appId }) {
|
|
11
11
|
await command.deferReply({ ephemeral: false });
|
|
12
12
|
const projectId = command.options.getString('project', true);
|
|
13
13
|
const guild = command.guild;
|
package/dist/commands/agent.js
CHANGED
|
@@ -44,7 +44,9 @@ export async function handleAgentCommand({ interaction, appId, }) {
|
|
|
44
44
|
targetChannelId = channel.id;
|
|
45
45
|
}
|
|
46
46
|
else {
|
|
47
|
-
await interaction.editReply({
|
|
47
|
+
await interaction.editReply({
|
|
48
|
+
content: 'This command can only be used in text channels or threads',
|
|
49
|
+
});
|
|
48
50
|
return;
|
|
49
51
|
}
|
|
50
52
|
if (channelAppId && channelAppId !== appId) {
|
|
@@ -52,7 +54,9 @@ export async function handleAgentCommand({ interaction, appId, }) {
|
|
|
52
54
|
return;
|
|
53
55
|
}
|
|
54
56
|
if (!projectDirectory) {
|
|
55
|
-
await interaction.editReply({
|
|
57
|
+
await interaction.editReply({
|
|
58
|
+
content: 'This channel is not configured with a project directory',
|
|
59
|
+
});
|
|
56
60
|
return;
|
|
57
61
|
}
|
|
58
62
|
try {
|
|
@@ -44,9 +44,10 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
|
|
|
44
44
|
description: 'Provide a custom answer in chat',
|
|
45
45
|
},
|
|
46
46
|
];
|
|
47
|
+
const placeholder = options.find((x) => x.label)?.label || 'Select an option';
|
|
47
48
|
const selectMenu = new StringSelectMenuBuilder()
|
|
48
49
|
.setCustomId(`ask_question:${contextHash}:${i}`)
|
|
49
|
-
.setPlaceholder(
|
|
50
|
+
.setPlaceholder(placeholder)
|
|
50
51
|
.addOptions(options);
|
|
51
52
|
// Enable multi-select if the question supports it
|
|
52
53
|
if (q.multiple) {
|
package/dist/commands/fork.js
CHANGED
|
@@ -84,8 +84,7 @@ export async function handleForkCommand(interaction) {
|
|
|
84
84
|
.setCustomId(`fork_select:${sessionId}:${encodedDir}`)
|
|
85
85
|
.setPlaceholder('Select a message to fork from')
|
|
86
86
|
.addOptions(options);
|
|
87
|
-
const actionRow = new ActionRowBuilder()
|
|
88
|
-
.addComponents(selectMenu);
|
|
87
|
+
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
89
88
|
await interaction.editReply({
|
|
90
89
|
content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
|
|
91
90
|
components: [actionRow],
|
|
@@ -133,11 +132,12 @@ export async function handleForkSelectMenu(interaction) {
|
|
|
133
132
|
}
|
|
134
133
|
const forkedSession = forkResponse.data;
|
|
135
134
|
const parentChannel = interaction.channel;
|
|
136
|
-
if (!parentChannel ||
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
if (!parentChannel ||
|
|
136
|
+
![
|
|
137
|
+
ChannelType.PublicThread,
|
|
138
|
+
ChannelType.PrivateThread,
|
|
139
|
+
ChannelType.AnnouncementThread,
|
|
140
|
+
].includes(parentChannel.type)) {
|
|
141
141
|
await interaction.editReply('Could not access parent channel');
|
|
142
142
|
return;
|
|
143
143
|
}
|
package/dist/commands/queue.js
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveTextChannel, getKimakiMetadata, sendThreadMessage, SILENT_MESSAG
|
|
|
5
5
|
import { handleOpencodeSession, abortControllers, addToQueue, getQueueLength, clearQueue, } from '../session-handler.js';
|
|
6
6
|
import { createLogger } from '../logger.js';
|
|
7
7
|
const logger = createLogger('QUEUE');
|
|
8
|
-
export async function handleQueueCommand({ command
|
|
8
|
+
export async function handleQueueCommand({ command }) {
|
|
9
9
|
const message = command.options.getString('message', true);
|
|
10
10
|
const channel = command.channel;
|
|
11
11
|
if (!channel) {
|
|
@@ -88,7 +88,7 @@ export async function handleQueueCommand({ command, }) {
|
|
|
88
88
|
});
|
|
89
89
|
logger.log(`[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`);
|
|
90
90
|
}
|
|
91
|
-
export async function handleClearQueueCommand({ command
|
|
91
|
+
export async function handleClearQueueCommand({ command }) {
|
|
92
92
|
const channel = command.channel;
|
|
93
93
|
if (!channel) {
|
|
94
94
|
await command.reply({
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// /remove-project command - Remove Discord channels for a project.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getDatabase } from '../database.js';
|
|
4
|
+
import { createLogger } from '../logger.js';
|
|
5
|
+
import { abbreviatePath } from '../utils.js';
|
|
6
|
+
const logger = createLogger('REMOVE-PROJECT');
|
|
7
|
+
export async function handleRemoveProjectCommand({ command, appId }) {
|
|
8
|
+
await command.deferReply({ ephemeral: false });
|
|
9
|
+
const directory = command.options.getString('project', true);
|
|
10
|
+
const guild = command.guild;
|
|
11
|
+
if (!guild) {
|
|
12
|
+
await command.editReply('This command can only be used in a guild');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const db = getDatabase();
|
|
17
|
+
// Get channel IDs for this directory
|
|
18
|
+
const channels = db
|
|
19
|
+
.prepare('SELECT channel_id, channel_type FROM channel_directories WHERE directory = ?')
|
|
20
|
+
.all(directory);
|
|
21
|
+
if (channels.length === 0) {
|
|
22
|
+
await command.editReply(`No channels found for directory: \`${directory}\``);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const deletedChannels = [];
|
|
26
|
+
const failedChannels = [];
|
|
27
|
+
for (const { channel_id, channel_type } of channels) {
|
|
28
|
+
try {
|
|
29
|
+
const channel = await guild.channels.fetch(channel_id).catch(() => null);
|
|
30
|
+
if (channel) {
|
|
31
|
+
await channel.delete(`Removed by /remove-project command`);
|
|
32
|
+
deletedChannels.push(`${channel_type}: ${channel_id}`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
// Channel doesn't exist in this guild or was already deleted
|
|
36
|
+
deletedChannels.push(`${channel_type}: ${channel_id} (already deleted)`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
logger.error(`Failed to delete channel ${channel_id}:`, error);
|
|
41
|
+
failedChannels.push(`${channel_type}: ${channel_id}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Remove from database
|
|
45
|
+
db.prepare('DELETE FROM channel_directories WHERE directory = ?').run(directory);
|
|
46
|
+
const projectName = path.basename(directory);
|
|
47
|
+
let message = `Removed project **${projectName}**\n`;
|
|
48
|
+
message += `Directory: \`${directory}\`\n\n`;
|
|
49
|
+
if (deletedChannels.length > 0) {
|
|
50
|
+
message += `Deleted channels:\n${deletedChannels.map((c) => `- ${c}`).join('\n')}`;
|
|
51
|
+
}
|
|
52
|
+
if (failedChannels.length > 0) {
|
|
53
|
+
message += `\n\nFailed to delete (may be in another server):\n${failedChannels.map((c) => `- ${c}`).join('\n')}`;
|
|
54
|
+
}
|
|
55
|
+
await command.editReply(message);
|
|
56
|
+
logger.log(`Removed project ${projectName} at ${directory}`);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
logger.error('[REMOVE-PROJECT] Error:', error);
|
|
60
|
+
await command.editReply(`Failed to remove project: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function handleRemoveProjectAutocomplete({ interaction, appId, }) {
|
|
64
|
+
const focusedValue = interaction.options.getFocused();
|
|
65
|
+
const guild = interaction.guild;
|
|
66
|
+
if (!guild) {
|
|
67
|
+
await interaction.respond([]);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const db = getDatabase();
|
|
72
|
+
// Get all directories with channels
|
|
73
|
+
const allChannels = db
|
|
74
|
+
.prepare('SELECT DISTINCT directory, channel_id FROM channel_directories WHERE channel_type = ?')
|
|
75
|
+
.all('text');
|
|
76
|
+
// Filter to only channels that exist in this guild
|
|
77
|
+
const projectsInGuild = [];
|
|
78
|
+
for (const { directory, channel_id } of allChannels) {
|
|
79
|
+
try {
|
|
80
|
+
const channel = await guild.channels.fetch(channel_id).catch(() => null);
|
|
81
|
+
if (channel) {
|
|
82
|
+
projectsInGuild.push({ directory, channelId: channel_id });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Channel not in this guild, skip
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const projects = projectsInGuild
|
|
90
|
+
.filter(({ directory }) => {
|
|
91
|
+
const baseName = path.basename(directory);
|
|
92
|
+
const searchText = `${baseName} ${directory}`.toLowerCase();
|
|
93
|
+
return searchText.includes(focusedValue.toLowerCase());
|
|
94
|
+
})
|
|
95
|
+
.slice(0, 25)
|
|
96
|
+
.map(({ directory }) => {
|
|
97
|
+
const name = `${path.basename(directory)} (${abbreviatePath(directory)})`;
|
|
98
|
+
return {
|
|
99
|
+
name: name.length > 100 ? name.slice(0, 99) + '...' : name,
|
|
100
|
+
value: directory,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
await interaction.respond(projects);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
logger.error('[AUTOCOMPLETE] Error fetching projects:', error);
|
|
107
|
+
await interaction.respond([]);
|
|
108
|
+
}
|
|
109
|
+
}
|