kimaki 0.4.24 → 0.4.26
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/bin.js +6 -1
- package/dist/acp-client.test.js +149 -0
- package/dist/ai-tool-to-genai.js +3 -0
- package/dist/channel-management.js +14 -9
- package/dist/cli.js +148 -17
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +98 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +313 -0
- package/dist/commands/permissions.js +126 -0
- package/dist/commands/queue.js +129 -0
- package/dist/commands/resume.js +145 -0
- package/dist/commands/session.js +142 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +161 -0
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +54 -0
- package/dist/discord-bot.js +35 -32
- package/dist/discord-utils.js +81 -15
- package/dist/format-tables.js +3 -0
- package/dist/genai-worker-wrapper.js +3 -0
- package/dist/genai-worker.js +3 -0
- package/dist/genai.js +3 -0
- package/dist/interaction-handler.js +89 -695
- package/dist/logger.js +46 -5
- package/dist/markdown.js +107 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +113 -28
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +73 -16
- package/dist/session-handler.js +176 -63
- package/dist/system-message.js +7 -38
- package/dist/tools.js +3 -0
- package/dist/utils.js +3 -0
- package/dist/voice-handler.js +21 -8
- package/dist/voice.js +31 -12
- package/dist/worker-types.js +3 -0
- package/dist/xml.js +3 -0
- package/package.json +3 -3
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/ai-tool-to-genai.ts +4 -0
- package/src/channel-management.ts +24 -8
- package/src/cli.ts +163 -18
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +139 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/{fork.ts → commands/fork.ts} +40 -7
- package/src/{model-command.ts → commands/model.ts} +31 -9
- package/src/commands/permissions.ts +146 -0
- package/src/commands/queue.ts +181 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/session.ts +184 -0
- package/src/commands/share.ts +96 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +213 -0
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +65 -0
- package/src/discord-bot.ts +40 -33
- package/src/discord-utils.ts +88 -14
- package/src/format-tables.ts +4 -0
- package/src/genai-worker-wrapper.ts +4 -0
- package/src/genai-worker.ts +4 -0
- package/src/genai.ts +4 -0
- package/src/interaction-handler.ts +111 -924
- package/src/logger.ts +51 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +136 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +143 -30
- package/src/opencode.ts +84 -21
- package/src/session-handler.ts +248 -91
- package/src/system-message.ts +8 -38
- package/src/tools.ts +4 -0
- package/src/utils.ts +4 -0
- package/src/voice-handler.ts +24 -9
- package/src/voice.ts +36 -13
- package/src/worker-types.ts +4 -0
- package/src/xml.ts +4 -0
- package/README.md +0 -48
package/bin.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// CLI entrypoint with automatic restart capability.
|
|
4
|
+
// Spawns the main CLI and restarts it on non-zero exit codes with throttling.
|
|
5
|
+
// Exit codes 0, 130 (SIGINT), 143 (SIGTERM), or 64 (EXIT_NO_RESTART) terminate cleanly.
|
|
6
|
+
|
|
2
7
|
import { spawn } from 'node:child_process'
|
|
3
8
|
import { fileURLToPath } from 'node:url'
|
|
4
9
|
import { dirname, join } from 'node:path'
|
|
@@ -62,4 +67,4 @@ async function run() {
|
|
|
62
67
|
run().catch(err => {
|
|
63
68
|
console.error('Fatal error:', err)
|
|
64
69
|
process.exit(1)
|
|
65
|
-
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// ACP client integration test for the OpenCode ACP server.
|
|
2
|
+
import { afterAll, beforeAll, expect, test } from 'vitest';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { Readable, Writable } from 'node:stream';
|
|
5
|
+
import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream, } from '@agentclientprotocol/sdk';
|
|
6
|
+
const UPDATE_TIMEOUT_MS = 5000;
|
|
7
|
+
class TestClient {
|
|
8
|
+
updates = [];
|
|
9
|
+
listeners = [];
|
|
10
|
+
async requestPermission(params) {
|
|
11
|
+
const firstOption = params.options[0];
|
|
12
|
+
if (!firstOption) {
|
|
13
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
outcome: {
|
|
17
|
+
outcome: 'selected',
|
|
18
|
+
optionId: firstOption.optionId,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async sessionUpdate(params) {
|
|
23
|
+
this.updates.push(params);
|
|
24
|
+
const matching = this.listeners.filter((listener) => {
|
|
25
|
+
return listener.predicate(params);
|
|
26
|
+
});
|
|
27
|
+
this.listeners = this.listeners.filter((listener) => {
|
|
28
|
+
return !matching.includes(listener);
|
|
29
|
+
});
|
|
30
|
+
matching.forEach((listener) => {
|
|
31
|
+
clearTimeout(listener.timeout);
|
|
32
|
+
listener.resolve(params);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
resetUpdates() {
|
|
36
|
+
this.updates = [];
|
|
37
|
+
}
|
|
38
|
+
waitForUpdate({ predicate, timeoutMs = UPDATE_TIMEOUT_MS, }) {
|
|
39
|
+
const existing = this.updates.find((update) => {
|
|
40
|
+
return predicate(update);
|
|
41
|
+
});
|
|
42
|
+
if (existing) {
|
|
43
|
+
return Promise.resolve(existing);
|
|
44
|
+
}
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const timeout = setTimeout(() => {
|
|
47
|
+
this.listeners = this.listeners.filter((listener) => {
|
|
48
|
+
return listener.resolve !== resolve;
|
|
49
|
+
});
|
|
50
|
+
reject(new Error('Timed out waiting for session update'));
|
|
51
|
+
}, timeoutMs);
|
|
52
|
+
const listener = {
|
|
53
|
+
predicate,
|
|
54
|
+
resolve,
|
|
55
|
+
reject,
|
|
56
|
+
timeout,
|
|
57
|
+
};
|
|
58
|
+
this.listeners = [...this.listeners, listener];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
let serverProcess = null;
|
|
63
|
+
let connection = null;
|
|
64
|
+
let client = null;
|
|
65
|
+
let initResult = null;
|
|
66
|
+
let sessionId = null;
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
serverProcess = spawn('opencode', ['acp', '--port', '0', '--cwd', process.cwd()], {
|
|
69
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
70
|
+
});
|
|
71
|
+
if (!serverProcess.stdin || !serverProcess.stdout) {
|
|
72
|
+
throw new Error('Failed to open ACP stdio streams');
|
|
73
|
+
}
|
|
74
|
+
const input = Writable.toWeb(serverProcess.stdin);
|
|
75
|
+
const output = Readable.toWeb(serverProcess.stdout);
|
|
76
|
+
const localClient = new TestClient();
|
|
77
|
+
client = localClient;
|
|
78
|
+
const stream = ndJsonStream(input, output);
|
|
79
|
+
connection = new ClientSideConnection(() => localClient, stream);
|
|
80
|
+
initResult = await connection.initialize({
|
|
81
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
82
|
+
clientCapabilities: {
|
|
83
|
+
fs: {
|
|
84
|
+
readTextFile: false,
|
|
85
|
+
writeTextFile: false,
|
|
86
|
+
},
|
|
87
|
+
terminal: false,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}, 30000);
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
if (serverProcess) {
|
|
93
|
+
serverProcess.kill('SIGTERM');
|
|
94
|
+
await new Promise((resolve) => {
|
|
95
|
+
setTimeout(resolve, 500);
|
|
96
|
+
});
|
|
97
|
+
if (!serverProcess.killed) {
|
|
98
|
+
serverProcess.kill('SIGKILL');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
test('creates a session and receives prompt events', async () => {
|
|
103
|
+
if (!connection || !client) {
|
|
104
|
+
throw new Error('ACP connection not initialized');
|
|
105
|
+
}
|
|
106
|
+
const sessionResponse = await connection.newSession({
|
|
107
|
+
cwd: process.cwd(),
|
|
108
|
+
mcpServers: [],
|
|
109
|
+
});
|
|
110
|
+
sessionId = sessionResponse.sessionId;
|
|
111
|
+
const promptResponse = await connection.prompt({
|
|
112
|
+
sessionId,
|
|
113
|
+
prompt: [{ type: 'text', text: 'Hello from ACP test.' }],
|
|
114
|
+
});
|
|
115
|
+
const update = await client.waitForUpdate({
|
|
116
|
+
predicate: (notification) => {
|
|
117
|
+
return (notification.sessionId === sessionId &&
|
|
118
|
+
notification.update.sessionUpdate === 'agent_message_chunk');
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
expect(promptResponse.stopReason).toBeTruthy();
|
|
122
|
+
expect(update.update.sessionUpdate).toBe('agent_message_chunk');
|
|
123
|
+
}, 30000);
|
|
124
|
+
test('loads an existing session and streams history', async () => {
|
|
125
|
+
if (!connection || !client) {
|
|
126
|
+
throw new Error('ACP connection not initialized');
|
|
127
|
+
}
|
|
128
|
+
if (!initResult?.agentCapabilities?.loadSession) {
|
|
129
|
+
expect(true).toBe(true);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!sessionId) {
|
|
133
|
+
throw new Error('Missing session ID from previous test');
|
|
134
|
+
}
|
|
135
|
+
client.resetUpdates();
|
|
136
|
+
const loadPromise = connection.loadSession({
|
|
137
|
+
sessionId,
|
|
138
|
+
cwd: process.cwd(),
|
|
139
|
+
mcpServers: [],
|
|
140
|
+
});
|
|
141
|
+
const update = await client.waitForUpdate({
|
|
142
|
+
predicate: (notification) => {
|
|
143
|
+
return (notification.sessionId === sessionId &&
|
|
144
|
+
notification.update.sessionUpdate === 'user_message_chunk');
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
await loadPromise;
|
|
148
|
+
expect(update.update.sessionUpdate).toBe('user_message_chunk');
|
|
149
|
+
}, 30000);
|
package/dist/ai-tool-to-genai.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// AI SDK to Google GenAI tool converter.
|
|
2
|
+
// Transforms Vercel AI SDK tool definitions into Google GenAI CallableTool format
|
|
3
|
+
// for use with Gemini's function calling in the voice assistant.
|
|
1
4
|
import { Type } from '@google/genai';
|
|
2
5
|
import { z, toJSONSchema } from 'zod';
|
|
3
6
|
/**
|
|
@@ -1,45 +1,50 @@
|
|
|
1
|
+
// Discord channel and category management.
|
|
2
|
+
// Creates and manages Kimaki project channels (text + voice pairs),
|
|
3
|
+
// extracts channel metadata from topic tags, and ensures category structure.
|
|
1
4
|
import { ChannelType, } from 'discord.js';
|
|
2
5
|
import path from 'node:path';
|
|
3
6
|
import { getDatabase } from './database.js';
|
|
4
7
|
import { extractTagsArrays } from './xml.js';
|
|
5
|
-
export async function ensureKimakiCategory(guild) {
|
|
8
|
+
export async function ensureKimakiCategory(guild, botName) {
|
|
9
|
+
const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki';
|
|
6
10
|
const existingCategory = guild.channels.cache.find((channel) => {
|
|
7
11
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
8
12
|
return false;
|
|
9
13
|
}
|
|
10
|
-
return channel.name.toLowerCase() ===
|
|
14
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase();
|
|
11
15
|
});
|
|
12
16
|
if (existingCategory) {
|
|
13
17
|
return existingCategory;
|
|
14
18
|
}
|
|
15
19
|
return guild.channels.create({
|
|
16
|
-
name:
|
|
20
|
+
name: categoryName,
|
|
17
21
|
type: ChannelType.GuildCategory,
|
|
18
22
|
});
|
|
19
23
|
}
|
|
20
|
-
export async function ensureKimakiAudioCategory(guild) {
|
|
24
|
+
export async function ensureKimakiAudioCategory(guild, botName) {
|
|
25
|
+
const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
|
|
21
26
|
const existingCategory = guild.channels.cache.find((channel) => {
|
|
22
27
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
23
28
|
return false;
|
|
24
29
|
}
|
|
25
|
-
return channel.name.toLowerCase() ===
|
|
30
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase();
|
|
26
31
|
});
|
|
27
32
|
if (existingCategory) {
|
|
28
33
|
return existingCategory;
|
|
29
34
|
}
|
|
30
35
|
return guild.channels.create({
|
|
31
|
-
name:
|
|
36
|
+
name: categoryName,
|
|
32
37
|
type: ChannelType.GuildCategory,
|
|
33
38
|
});
|
|
34
39
|
}
|
|
35
|
-
export async function createProjectChannels({ guild, projectDirectory, appId, }) {
|
|
40
|
+
export async function createProjectChannels({ guild, projectDirectory, appId, botName, }) {
|
|
36
41
|
const baseName = path.basename(projectDirectory);
|
|
37
42
|
const channelName = `${baseName}`
|
|
38
43
|
.toLowerCase()
|
|
39
44
|
.replace(/[^a-z0-9-]/g, '-')
|
|
40
45
|
.slice(0, 100);
|
|
41
|
-
const kimakiCategory = await ensureKimakiCategory(guild);
|
|
42
|
-
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild);
|
|
46
|
+
const kimakiCategory = await ensureKimakiCategory(guild, botName);
|
|
47
|
+
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName);
|
|
43
48
|
const textChannel = await guild.channels.create({
|
|
44
49
|
name: channelName,
|
|
45
50
|
type: ChannelType.GuildText,
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Main CLI entrypoint for the Kimaki Discord bot.
|
|
3
|
+
// Handles interactive setup, Discord OAuth, slash command registration,
|
|
4
|
+
// project channel creation, and launching the bot with opencode integration.
|
|
2
5
|
import { cac } from 'cac';
|
|
3
6
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
|
|
4
7
|
import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
|
|
@@ -13,35 +16,91 @@ const cliLogger = createLogger('CLI');
|
|
|
13
16
|
const cli = cac('kimaki');
|
|
14
17
|
process.title = 'kimaki';
|
|
15
18
|
const LOCK_PORT = 29988;
|
|
19
|
+
async function killProcessOnPort(port) {
|
|
20
|
+
const isWindows = process.platform === 'win32';
|
|
21
|
+
const myPid = process.pid;
|
|
22
|
+
try {
|
|
23
|
+
if (isWindows) {
|
|
24
|
+
// Windows: find PID using netstat, then kill
|
|
25
|
+
const result = spawnSync('cmd', ['/c', `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`], {
|
|
26
|
+
shell: false,
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
});
|
|
29
|
+
const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p));
|
|
30
|
+
// Filter out our own PID and take the first (oldest)
|
|
31
|
+
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
|
|
32
|
+
if (targetPid) {
|
|
33
|
+
cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`);
|
|
34
|
+
spawnSync('taskkill', ['/F', '/PID', targetPid], { shell: false });
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// Unix: use lsof with -sTCP:LISTEN to only find the listening process
|
|
40
|
+
const result = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t'], {
|
|
41
|
+
shell: false,
|
|
42
|
+
encoding: 'utf-8',
|
|
43
|
+
});
|
|
44
|
+
const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p));
|
|
45
|
+
// Filter out our own PID and take the first (oldest)
|
|
46
|
+
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
|
|
47
|
+
if (targetPid) {
|
|
48
|
+
const pid = parseInt(targetPid, 10);
|
|
49
|
+
cliLogger.log(`Stopping existing kimaki process (PID: ${pid})`);
|
|
50
|
+
process.kill(pid, 'SIGKILL');
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
cliLogger.debug(`Failed to kill process on port ${port}:`, e);
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
16
60
|
async function checkSingleInstance() {
|
|
17
61
|
try {
|
|
18
62
|
const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
|
|
19
63
|
signal: AbortSignal.timeout(1000),
|
|
20
64
|
});
|
|
21
65
|
if (response.ok) {
|
|
22
|
-
cliLogger.
|
|
23
|
-
|
|
66
|
+
cliLogger.log('Another kimaki instance detected');
|
|
67
|
+
await killProcessOnPort(LOCK_PORT);
|
|
68
|
+
// Wait a moment for port to be released
|
|
69
|
+
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
24
70
|
}
|
|
25
71
|
}
|
|
26
72
|
catch {
|
|
27
|
-
|
|
73
|
+
cliLogger.debug('No other kimaki instance detected on lock port');
|
|
28
74
|
}
|
|
29
75
|
}
|
|
30
|
-
function startLockServer() {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
76
|
+
async function startLockServer() {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const server = http.createServer((req, res) => {
|
|
79
|
+
res.writeHead(200);
|
|
80
|
+
res.end('kimaki');
|
|
81
|
+
});
|
|
82
|
+
server.listen(LOCK_PORT, '127.0.0.1');
|
|
83
|
+
server.once('listening', () => {
|
|
84
|
+
resolve();
|
|
85
|
+
});
|
|
86
|
+
server.on('error', async (err) => {
|
|
87
|
+
if (err.code === 'EADDRINUSE') {
|
|
88
|
+
cliLogger.log('Port still in use, retrying...');
|
|
89
|
+
await killProcessOnPort(LOCK_PORT);
|
|
90
|
+
await new Promise((r) => { setTimeout(r, 500); });
|
|
91
|
+
// Retry once
|
|
92
|
+
server.listen(LOCK_PORT, '127.0.0.1');
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
reject(err);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
41
98
|
});
|
|
42
99
|
}
|
|
43
100
|
const EXIT_NO_RESTART = 64;
|
|
44
|
-
|
|
101
|
+
// Commands to skip when registering user commands (reserved names)
|
|
102
|
+
const SKIP_USER_COMMANDS = ['init'];
|
|
103
|
+
async function registerCommands(token, appId, userCommands = []) {
|
|
45
104
|
const commands = [
|
|
46
105
|
new SlashCommandBuilder()
|
|
47
106
|
.setName('resume')
|
|
@@ -113,6 +172,10 @@ async function registerCommands(token, appId) {
|
|
|
113
172
|
.setName('abort')
|
|
114
173
|
.setDescription('Abort the current OpenCode request in this thread')
|
|
115
174
|
.toJSON(),
|
|
175
|
+
new SlashCommandBuilder()
|
|
176
|
+
.setName('stop')
|
|
177
|
+
.setDescription('Abort the current OpenCode request in this thread')
|
|
178
|
+
.toJSON(),
|
|
116
179
|
new SlashCommandBuilder()
|
|
117
180
|
.setName('share')
|
|
118
181
|
.setDescription('Share the current session as a public URL')
|
|
@@ -125,7 +188,53 @@ async function registerCommands(token, appId) {
|
|
|
125
188
|
.setName('model')
|
|
126
189
|
.setDescription('Set the preferred model for this channel or session')
|
|
127
190
|
.toJSON(),
|
|
191
|
+
new SlashCommandBuilder()
|
|
192
|
+
.setName('agent')
|
|
193
|
+
.setDescription('Set the preferred agent for this channel or session')
|
|
194
|
+
.toJSON(),
|
|
195
|
+
new SlashCommandBuilder()
|
|
196
|
+
.setName('queue')
|
|
197
|
+
.setDescription('Queue a message to be sent after the current response finishes')
|
|
198
|
+
.addStringOption((option) => {
|
|
199
|
+
option
|
|
200
|
+
.setName('message')
|
|
201
|
+
.setDescription('The message to queue')
|
|
202
|
+
.setRequired(true);
|
|
203
|
+
return option;
|
|
204
|
+
})
|
|
205
|
+
.toJSON(),
|
|
206
|
+
new SlashCommandBuilder()
|
|
207
|
+
.setName('clear-queue')
|
|
208
|
+
.setDescription('Clear all queued messages in this thread')
|
|
209
|
+
.toJSON(),
|
|
210
|
+
new SlashCommandBuilder()
|
|
211
|
+
.setName('undo')
|
|
212
|
+
.setDescription('Undo the last assistant message (revert file changes)')
|
|
213
|
+
.toJSON(),
|
|
214
|
+
new SlashCommandBuilder()
|
|
215
|
+
.setName('redo')
|
|
216
|
+
.setDescription('Redo previously undone changes')
|
|
217
|
+
.toJSON(),
|
|
128
218
|
];
|
|
219
|
+
// Add user-defined commands with -cmd suffix
|
|
220
|
+
for (const cmd of userCommands) {
|
|
221
|
+
if (SKIP_USER_COMMANDS.includes(cmd.name)) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const commandName = `${cmd.name}-cmd`;
|
|
225
|
+
const description = cmd.description || `Run /${cmd.name} command`;
|
|
226
|
+
commands.push(new SlashCommandBuilder()
|
|
227
|
+
.setName(commandName)
|
|
228
|
+
.setDescription(description.slice(0, 100)) // Discord limits to 100 chars
|
|
229
|
+
.addStringOption((option) => {
|
|
230
|
+
option
|
|
231
|
+
.setName('arguments')
|
|
232
|
+
.setDescription('Arguments to pass to the command')
|
|
233
|
+
.setRequired(false);
|
|
234
|
+
return option;
|
|
235
|
+
})
|
|
236
|
+
.toJSON());
|
|
237
|
+
}
|
|
129
238
|
const rest = new REST().setToken(token);
|
|
130
239
|
try {
|
|
131
240
|
const data = (await rest.put(Routes.applicationCommands(appId), {
|
|
@@ -414,6 +523,7 @@ async function run({ restart, addChannels }) {
|
|
|
414
523
|
guild: targetGuild,
|
|
415
524
|
projectDirectory: project.worktree,
|
|
416
525
|
appId,
|
|
526
|
+
botName: discordClient.user?.username,
|
|
417
527
|
});
|
|
418
528
|
createdChannels.push({
|
|
419
529
|
name: channelName,
|
|
@@ -431,8 +541,29 @@ async function run({ restart, addChannels }) {
|
|
|
431
541
|
}
|
|
432
542
|
}
|
|
433
543
|
}
|
|
544
|
+
// Fetch user-defined commands using the already-running server
|
|
545
|
+
const allUserCommands = [];
|
|
546
|
+
try {
|
|
547
|
+
const commandsResponse = await getClient().command.list({
|
|
548
|
+
query: { directory: currentDir },
|
|
549
|
+
});
|
|
550
|
+
if (commandsResponse.data) {
|
|
551
|
+
allUserCommands.push(...commandsResponse.data);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
// Ignore errors fetching commands
|
|
556
|
+
}
|
|
557
|
+
// Log available user commands
|
|
558
|
+
const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
|
|
559
|
+
if (registrableCommands.length > 0) {
|
|
560
|
+
const commandList = registrableCommands
|
|
561
|
+
.map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
|
|
562
|
+
.join('\n');
|
|
563
|
+
note(`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`, 'OpenCode Commands');
|
|
564
|
+
}
|
|
434
565
|
cliLogger.log('Registering slash commands asynchronously...');
|
|
435
|
-
void registerCommands(token, appId)
|
|
566
|
+
void registerCommands(token, appId, allUserCommands)
|
|
436
567
|
.then(() => {
|
|
437
568
|
cliLogger.log('Slash commands registered!');
|
|
438
569
|
})
|
|
@@ -469,7 +600,7 @@ cli
|
|
|
469
600
|
.action(async (options) => {
|
|
470
601
|
try {
|
|
471
602
|
await checkSingleInstance();
|
|
472
|
-
startLockServer();
|
|
603
|
+
await startLockServer();
|
|
473
604
|
await run({
|
|
474
605
|
restart: options.restart,
|
|
475
606
|
addChannels: options.addChannels,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// /abort command - Abort the current OpenCode request in this thread.
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { getDatabase } from '../database.js';
|
|
4
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
5
|
+
import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
|
+
import { abortControllers } from '../session-handler.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
const logger = createLogger('ABORT');
|
|
9
|
+
export async function handleAbortCommand({ command, }) {
|
|
10
|
+
const channel = command.channel;
|
|
11
|
+
if (!channel) {
|
|
12
|
+
await command.reply({
|
|
13
|
+
content: 'This command can only be used in a channel',
|
|
14
|
+
ephemeral: true,
|
|
15
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
16
|
+
});
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const isThread = [
|
|
20
|
+
ChannelType.PublicThread,
|
|
21
|
+
ChannelType.PrivateThread,
|
|
22
|
+
ChannelType.AnnouncementThread,
|
|
23
|
+
].includes(channel.type);
|
|
24
|
+
if (!isThread) {
|
|
25
|
+
await command.reply({
|
|
26
|
+
content: 'This command can only be used in a thread with an active session',
|
|
27
|
+
ephemeral: true,
|
|
28
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const textChannel = await resolveTextChannel(channel);
|
|
33
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel);
|
|
34
|
+
if (!directory) {
|
|
35
|
+
await command.reply({
|
|
36
|
+
content: 'Could not determine project directory for this channel',
|
|
37
|
+
ephemeral: true,
|
|
38
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const row = getDatabase()
|
|
43
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
44
|
+
.get(channel.id);
|
|
45
|
+
if (!row?.session_id) {
|
|
46
|
+
await command.reply({
|
|
47
|
+
content: 'No active session in this thread',
|
|
48
|
+
ephemeral: true,
|
|
49
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const sessionId = row.session_id;
|
|
54
|
+
try {
|
|
55
|
+
const existingController = abortControllers.get(sessionId);
|
|
56
|
+
if (existingController) {
|
|
57
|
+
existingController.abort(new Error('User requested abort'));
|
|
58
|
+
abortControllers.delete(sessionId);
|
|
59
|
+
}
|
|
60
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
61
|
+
await getClient().session.abort({
|
|
62
|
+
path: { id: sessionId },
|
|
63
|
+
});
|
|
64
|
+
await command.reply({
|
|
65
|
+
content: `🛑 Request **aborted**`,
|
|
66
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
67
|
+
});
|
|
68
|
+
logger.log(`Session ${sessionId} aborted by user`);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
logger.error('[ABORT] Error:', error);
|
|
72
|
+
await command.reply({
|
|
73
|
+
content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
74
|
+
ephemeral: true,
|
|
75
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// /add-project command - Create Discord channels for an existing OpenCode project.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getDatabase } from '../database.js';
|
|
5
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
6
|
+
import { createProjectChannels } from '../channel-management.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
const logger = createLogger('ADD-PROJECT');
|
|
9
|
+
export async function handleAddProjectCommand({ command, appId, }) {
|
|
10
|
+
await command.deferReply({ ephemeral: false });
|
|
11
|
+
const projectId = command.options.getString('project', true);
|
|
12
|
+
const guild = command.guild;
|
|
13
|
+
if (!guild) {
|
|
14
|
+
await command.editReply('This command can only be used in a guild');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const currentDir = process.cwd();
|
|
19
|
+
const getClient = await initializeOpencodeForDirectory(currentDir);
|
|
20
|
+
const projectsResponse = await getClient().project.list({});
|
|
21
|
+
if (!projectsResponse.data) {
|
|
22
|
+
await command.editReply('Failed to fetch projects');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const project = projectsResponse.data.find((p) => p.id === projectId);
|
|
26
|
+
if (!project) {
|
|
27
|
+
await command.editReply('Project not found');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const directory = project.worktree;
|
|
31
|
+
if (!fs.existsSync(directory)) {
|
|
32
|
+
await command.editReply(`Directory does not exist: ${directory}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const db = getDatabase();
|
|
36
|
+
const existingChannel = db
|
|
37
|
+
.prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?')
|
|
38
|
+
.get(directory, 'text');
|
|
39
|
+
if (existingChannel) {
|
|
40
|
+
await command.editReply(`A channel already exists for this directory: <#${existingChannel.channel_id}>`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
44
|
+
guild,
|
|
45
|
+
projectDirectory: directory,
|
|
46
|
+
appId,
|
|
47
|
+
botName: command.client.user?.username,
|
|
48
|
+
});
|
|
49
|
+
await command.editReply(`✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``);
|
|
50
|
+
logger.log(`Created channels for project ${channelName} at ${directory}`);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
logger.error('[ADD-PROJECT] Error:', error);
|
|
54
|
+
await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function handleAddProjectAutocomplete({ interaction, appId, }) {
|
|
58
|
+
const focusedValue = interaction.options.getFocused();
|
|
59
|
+
try {
|
|
60
|
+
const currentDir = process.cwd();
|
|
61
|
+
const getClient = await initializeOpencodeForDirectory(currentDir);
|
|
62
|
+
const projectsResponse = await getClient().project.list({});
|
|
63
|
+
if (!projectsResponse.data) {
|
|
64
|
+
await interaction.respond([]);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const db = getDatabase();
|
|
68
|
+
const existingDirs = db
|
|
69
|
+
.prepare('SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?')
|
|
70
|
+
.all('text');
|
|
71
|
+
const existingDirSet = new Set(existingDirs.map((row) => row.directory));
|
|
72
|
+
const availableProjects = projectsResponse.data.filter((project) => !existingDirSet.has(project.worktree));
|
|
73
|
+
const projects = availableProjects
|
|
74
|
+
.filter((project) => {
|
|
75
|
+
const baseName = path.basename(project.worktree);
|
|
76
|
+
const searchText = `${baseName} ${project.worktree}`.toLowerCase();
|
|
77
|
+
return searchText.includes(focusedValue.toLowerCase());
|
|
78
|
+
})
|
|
79
|
+
.sort((a, b) => {
|
|
80
|
+
const aTime = a.time.initialized || a.time.created;
|
|
81
|
+
const bTime = b.time.initialized || b.time.created;
|
|
82
|
+
return bTime - aTime;
|
|
83
|
+
})
|
|
84
|
+
.slice(0, 25)
|
|
85
|
+
.map((project) => {
|
|
86
|
+
const name = `${path.basename(project.worktree)} (${project.worktree})`;
|
|
87
|
+
return {
|
|
88
|
+
name: name.length > 100 ? name.slice(0, 99) + '…' : name,
|
|
89
|
+
value: project.id,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
await interaction.respond(projects);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.error('[AUTOCOMPLETE] Error fetching projects:', error);
|
|
96
|
+
await interaction.respond([]);
|
|
97
|
+
}
|
|
98
|
+
}
|