kimaki 0.4.25 → 0.4.27
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/acp-client.test.js +149 -0
- package/dist/channel-management.js +11 -9
- package/dist/cli.js +58 -18
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +184 -0
- package/dist/commands/model.js +23 -4
- package/dist/commands/permissions.js +101 -105
- package/dist/commands/session.js +1 -3
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +51 -0
- package/dist/discord-bot.js +32 -32
- package/dist/discord-utils.js +71 -14
- package/dist/interaction-handler.js +25 -8
- package/dist/logger.js +43 -5
- package/dist/markdown.js +104 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +72 -22
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +70 -16
- package/dist/session-handler.js +142 -66
- package/dist/system-message.js +4 -51
- package/dist/voice-handler.js +18 -8
- package/dist/voice.js +28 -12
- package/package.json +14 -13
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/channel-management.ts +20 -8
- package/src/cli.ts +73 -19
- package/src/commands/add-project.ts +1 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +277 -0
- package/src/commands/fork.ts +1 -2
- package/src/commands/model.ts +24 -4
- package/src/commands/permissions.ts +139 -114
- package/src/commands/session.ts +1 -3
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +61 -0
- package/src/discord-bot.ts +36 -33
- package/src/discord-utils.ts +76 -14
- package/src/interaction-handler.ts +31 -10
- package/src/logger.ts +47 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +132 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +93 -25
- package/src/opencode.ts +80 -21
- package/src/session-handler.ts +190 -97
- package/src/system-message.ts +4 -51
- package/src/voice-handler.ts +20 -9
- package/src/voice.ts +32 -13
- package/LICENSE +0 -21
package/dist/voice.js
CHANGED
|
@@ -23,7 +23,8 @@ async function runGrep({ pattern, directory, }) {
|
|
|
23
23
|
.join('\n');
|
|
24
24
|
return output.slice(0, 2000);
|
|
25
25
|
}
|
|
26
|
-
catch {
|
|
26
|
+
catch (e) {
|
|
27
|
+
voiceLogger.error('grep search failed:', e);
|
|
27
28
|
return 'grep search failed';
|
|
28
29
|
}
|
|
29
30
|
}
|
|
@@ -204,7 +205,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
|
|
|
204
205
|
});
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
|
-
export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory,
|
|
208
|
+
export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, currentSessionContext, lastSessionContext, }) {
|
|
208
209
|
try {
|
|
209
210
|
const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
|
|
210
211
|
if (!apiKey) {
|
|
@@ -228,6 +229,21 @@ export async function transcribeAudio({ audio, prompt, language, temperature, ge
|
|
|
228
229
|
throw new Error('Invalid audio format');
|
|
229
230
|
}
|
|
230
231
|
const languageHint = language ? `The audio is in ${language}.\n\n` : '';
|
|
232
|
+
// build session context section
|
|
233
|
+
const sessionContextParts = [];
|
|
234
|
+
if (lastSessionContext) {
|
|
235
|
+
sessionContextParts.push(`<last_session>
|
|
236
|
+
${lastSessionContext}
|
|
237
|
+
</last_session>`);
|
|
238
|
+
}
|
|
239
|
+
if (currentSessionContext) {
|
|
240
|
+
sessionContextParts.push(`<current_session>
|
|
241
|
+
${currentSessionContext}
|
|
242
|
+
</current_session>`);
|
|
243
|
+
}
|
|
244
|
+
const sessionContextSection = sessionContextParts.length > 0
|
|
245
|
+
? `\nSession context (use to understand references to files, functions, tools used):\n${sessionContextParts.join('\n\n')}`
|
|
246
|
+
: '';
|
|
231
247
|
const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
|
|
232
248
|
|
|
233
249
|
CRITICAL REQUIREMENT: You MUST call the "transcriptionResult" tool to complete this task.
|
|
@@ -241,29 +257,29 @@ This is a software development environment. The speaker is giving instructions t
|
|
|
241
257
|
- File paths, function names, CLI commands, package names, API endpoints
|
|
242
258
|
|
|
243
259
|
RULES:
|
|
244
|
-
1.
|
|
245
|
-
2. If audio
|
|
246
|
-
3.
|
|
247
|
-
4. When warned about remaining steps, STOP searching and call transcriptionResult immediately
|
|
260
|
+
1. If audio is unclear, transcribe your best interpretation, interpreting words event with strong accents are present, identifying the accent being used first so you can guess what the words meawn
|
|
261
|
+
2. If audio seems silent/empty, call transcriptionResult with "[inaudible audio]"
|
|
262
|
+
3. Use the session context below to understand technical terms, file names, function names mentioned
|
|
248
263
|
|
|
249
264
|
Common corrections (apply without tool calls):
|
|
250
265
|
- "reacked" → "React", "jason" → "JSON", "get hub" → "GitHub", "no JS" → "Node.js", "dacker" → "Docker"
|
|
251
266
|
|
|
252
|
-
Project
|
|
253
|
-
<
|
|
267
|
+
Project file structure:
|
|
268
|
+
<file_tree>
|
|
254
269
|
${prompt}
|
|
255
|
-
</
|
|
256
|
-
${
|
|
270
|
+
</file_tree>
|
|
271
|
+
${sessionContextSection}
|
|
257
272
|
|
|
258
273
|
REMEMBER: Call "transcriptionResult" tool with your transcription. This is mandatory.
|
|
259
274
|
|
|
260
275
|
Note: "critique" is a CLI tool for showing diffs in the browser.`;
|
|
261
|
-
const hasDirectory = directory && directory.trim().length > 0
|
|
276
|
+
// const hasDirectory = directory && directory.trim().length > 0
|
|
262
277
|
const tools = [
|
|
263
278
|
{
|
|
264
279
|
functionDeclarations: [
|
|
265
280
|
transcriptionResultToolDeclaration,
|
|
266
|
-
|
|
281
|
+
// grep/glob disabled - was causing transcription to hang
|
|
282
|
+
// ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
|
|
267
283
|
],
|
|
268
284
|
},
|
|
269
285
|
];
|
package/package.json
CHANGED
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.27",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
|
+
"prepublishOnly": "pnpm tsc",
|
|
9
|
+
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
10
|
+
"watch": "tsx scripts/watch-session.ts",
|
|
11
|
+
"test:events": "tsx test-events.ts",
|
|
12
|
+
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
13
|
+
"test:send": "tsx send-test-message.ts",
|
|
14
|
+
"register-commands": "tsx scripts/register-commands.ts"
|
|
15
|
+
},
|
|
6
16
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
17
|
"bin": "bin.js",
|
|
8
18
|
"files": [
|
|
@@ -11,7 +21,7 @@
|
|
|
11
21
|
"bin.js"
|
|
12
22
|
],
|
|
13
23
|
"devDependencies": {
|
|
14
|
-
"@opencode-ai/plugin": "^1.
|
|
24
|
+
"@opencode-ai/plugin": "^1.1.12",
|
|
15
25
|
"@types/better-sqlite3": "^7.6.13",
|
|
16
26
|
"@types/bun": "latest",
|
|
17
27
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -25,7 +35,7 @@
|
|
|
25
35
|
"@discordjs/opus": "^0.10.0",
|
|
26
36
|
"@discordjs/voice": "^0.19.0",
|
|
27
37
|
"@google/genai": "^1.34.0",
|
|
28
|
-
"@opencode-ai/sdk": "^1.
|
|
38
|
+
"@opencode-ai/sdk": "^1.1.12",
|
|
29
39
|
"@purinton/resampler": "^1.0.4",
|
|
30
40
|
"@snazzah/davey": "^0.1.6",
|
|
31
41
|
"ai": "^5.0.114",
|
|
@@ -45,14 +55,5 @@
|
|
|
45
55
|
"string-dedent": "^3.0.2",
|
|
46
56
|
"undici": "^7.16.0",
|
|
47
57
|
"zod": "^4.2.1"
|
|
48
|
-
},
|
|
49
|
-
"scripts": {
|
|
50
|
-
"dev": "tsx --env-file .env src/cli.ts",
|
|
51
|
-
"dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
|
|
52
|
-
"watch": "tsx scripts/watch-session.ts",
|
|
53
|
-
"test:events": "tsx test-events.ts",
|
|
54
|
-
"pcm-to-mp3": "bun scripts/pcm-to-mp3",
|
|
55
|
-
"test:send": "tsx send-test-message.ts",
|
|
56
|
-
"register-commands": "tsx scripts/register-commands.ts"
|
|
57
58
|
}
|
|
58
|
-
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[Assistant]: Still not showing. The session might not have a `system` field and the first assistant message might not have `synthetic: true`. Let me add debug logging to see what's available:
|
|
2
|
+
|
|
3
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
|
|
4
|
+
if (includeSystemPrompt) {
|
|
5
|
+
// try session.system fie, newString= // Get system prompt if requested
|
|
6
|
+
if (includeSystemPrompt) {
|
|
7
|
+
// try session.system fie
|
|
8
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= } else if (messages.length > 0) {
|
|
9
|
+
// fallback: look for synthetic text part in first a, newString= } else if (messages.length > 0) {
|
|
10
|
+
// fallback: look for synthetic text part in first a
|
|
11
|
+
[Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 | tail -30, description=run test and check output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
|
|
12
|
+
[Tool bash]: command=grep -i "session keys\|text parts" tmp/kimaki.log | tail -10, description=check debug output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord
|
|
13
|
+
[Assistant]: Session doesn't have a `system` field. Let me fix the debug logging:
|
|
14
|
+
|
|
15
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
|
|
16
|
+
, newString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
|
|
17
|
+
|
|
18
|
+
[Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 && grep -i "text parts" , description=run test and check debug, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
|
|
19
|
+
[Assistant]: I see - the first assistant text part has **no `synthetic` field** (it's `undefined`), and it's only 44 chars. The session doesn't expose the system prompt.
|
|
20
|
+
|
|
21
|
+
Let me remove debug logging and fix the test:
|
|
22
|
+
|
|
23
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
|
|
24
|
+
if (includeSystemPrompt) {
|
|
25
|
+
// try session.system fie, newString= // Get system prompt if requested
|
|
26
|
+
// Note: OpenCode SDK doesn't expose system prompt directl
|
|
27
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= try {
|
|
28
|
+
// fetch session info to get system prompt
|
|
29
|
+
const sessionResponse = await client.sess, newString= try {
|
|
30
|
+
const messagesResponse = await client.session.messages({
|
|
31
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.test.ts, oldString= expect(context).toBeTruthy()
|
|
32
|
+
expect(context.length).toBeGreaterThan(0)
|
|
33
|
+
// should have tool cal, newString= expect(context).toBeTruthy()
|
|
34
|
+
expect(context.length).toBeGreaterThan(0)
|
|
35
|
+
// should have tool cal
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[Tool bash]: command=grep -A5 -B5 "Session" node_modules/@opencode-ai/.ignored_sdk/dist/gen/core/types.gen.d.ts 2>/dev/nu, description=check session types, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord
|
|
2
|
+
[Tool read]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, offset=248, limit=35
|
|
3
|
+
[Assistant]: Let me update the code to also look for system role messages and fetch session info:
|
|
4
|
+
|
|
5
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= try {
|
|
6
|
+
const messagesResponse = await client.session.messages({
|
|
7
|
+
path: { id: sessionId },
|
|
8
|
+
, newString= try {
|
|
9
|
+
// fetch session info to get system prompt
|
|
10
|
+
const sessionResponse = await client.sess
|
|
11
|
+
[Tool bash]: command=pnpm vitest run -u --testNamePattern "getCompactSessionContext", description=run tests to check system prompt, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
|
|
12
|
+
[Tool bash]: command=head -30 discord/src/__snapshots__/compact-session-context.md, description=check if system prompt is in snapshot
|
|
13
|
+
[Assistant]: Still not showing. The session might not have a `system` field and the first assistant message might not have `synthetic: true`. Let me add debug logging to see what's available:
|
|
14
|
+
|
|
15
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
|
|
16
|
+
if (includeSystemPrompt) {
|
|
17
|
+
// try session.system fie, newString= // Get system prompt if requested
|
|
18
|
+
if (includeSystemPrompt) {
|
|
19
|
+
// try session.system fie
|
|
20
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= } else if (messages.length > 0) {
|
|
21
|
+
// fallback: look for synthetic text part in first a, newString= } else if (messages.length > 0) {
|
|
22
|
+
// fallback: look for synthetic text part in first a
|
|
23
|
+
[Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 | tail -30, description=run test and check output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
|
|
24
|
+
[Tool bash]: command=grep -i "session keys\|text parts" tmp/kimaki.log | tail -10, description=check debug output, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord
|
|
25
|
+
[Assistant]: Session doesn't have a `system` field. Let me fix the debug logging:
|
|
26
|
+
|
|
27
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
|
|
28
|
+
, newString= const textParts = (firstAssistant.parts || []).filter((p) => p.type === 'text')
|
|
29
|
+
|
|
30
|
+
[Tool bash]: command=pnpm vitest run --testNamePattern "getCompactSessionContext generates" 2>&1 && grep -i "text parts" , description=run test and check debug, workdir=/Users/morse/Documents/GitHub/kimakivoice/discord, timeout=120000
|
|
31
|
+
[Assistant]: I see - the first assistant text part has **no `synthetic` field** (it's `undefined`), and it's only 44 chars. The session doesn't expose the system prompt.
|
|
32
|
+
|
|
33
|
+
Let me remove debug logging and fix the test:
|
|
34
|
+
|
|
35
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= // Get system prompt if requested
|
|
36
|
+
if (includeSystemPrompt) {
|
|
37
|
+
// try session.system fie, newString= // Get system prompt if requested
|
|
38
|
+
// Note: OpenCode SDK doesn't expose system prompt directl
|
|
39
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.ts, oldString= try {
|
|
40
|
+
// fetch session info to get system prompt
|
|
41
|
+
const sessionResponse = await client.sess, newString= try {
|
|
42
|
+
const messagesResponse = await client.session.messages({
|
|
43
|
+
[Tool edit]: filePath=/Users/morse/Documents/GitHub/kimakivoice/discord/src/markdown.test.ts, oldString= expect(context).toBeTruthy()
|
|
44
|
+
expect(context.length).toBeGreaterThan(0)
|
|
45
|
+
// should have tool cal, newString= expect(context).toBeTruthy()
|
|
46
|
+
expect(context.length).toBeGreaterThan(0)
|
|
47
|
+
// should have tool cal
|
|
@@ -12,14 +12,19 @@ import path from 'node:path'
|
|
|
12
12
|
import { getDatabase } from './database.js'
|
|
13
13
|
import { extractTagsArrays } from './xml.js'
|
|
14
14
|
|
|
15
|
-
export async function ensureKimakiCategory(
|
|
15
|
+
export async function ensureKimakiCategory(
|
|
16
|
+
guild: Guild,
|
|
17
|
+
botName?: string,
|
|
18
|
+
): Promise<CategoryChannel> {
|
|
19
|
+
const categoryName = botName ? `Kimaki ${botName}` : 'Kimaki'
|
|
20
|
+
|
|
16
21
|
const existingCategory = guild.channels.cache.find(
|
|
17
22
|
(channel): channel is CategoryChannel => {
|
|
18
23
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
19
24
|
return false
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
return channel.name.toLowerCase() ===
|
|
27
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase()
|
|
23
28
|
},
|
|
24
29
|
)
|
|
25
30
|
|
|
@@ -28,19 +33,24 @@ export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChanne
|
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
return guild.channels.create({
|
|
31
|
-
name:
|
|
36
|
+
name: categoryName,
|
|
32
37
|
type: ChannelType.GuildCategory,
|
|
33
38
|
})
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
export async function ensureKimakiAudioCategory(
|
|
41
|
+
export async function ensureKimakiAudioCategory(
|
|
42
|
+
guild: Guild,
|
|
43
|
+
botName?: string,
|
|
44
|
+
): Promise<CategoryChannel> {
|
|
45
|
+
const categoryName = botName ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
|
|
46
|
+
|
|
37
47
|
const existingCategory = guild.channels.cache.find(
|
|
38
48
|
(channel): channel is CategoryChannel => {
|
|
39
49
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
40
50
|
return false
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
return channel.name.toLowerCase() ===
|
|
53
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase()
|
|
44
54
|
},
|
|
45
55
|
)
|
|
46
56
|
|
|
@@ -49,7 +59,7 @@ export async function ensureKimakiAudioCategory(guild: Guild): Promise<CategoryC
|
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
return guild.channels.create({
|
|
52
|
-
name:
|
|
62
|
+
name: categoryName,
|
|
53
63
|
type: ChannelType.GuildCategory,
|
|
54
64
|
})
|
|
55
65
|
}
|
|
@@ -58,10 +68,12 @@ export async function createProjectChannels({
|
|
|
58
68
|
guild,
|
|
59
69
|
projectDirectory,
|
|
60
70
|
appId,
|
|
71
|
+
botName,
|
|
61
72
|
}: {
|
|
62
73
|
guild: Guild
|
|
63
74
|
projectDirectory: string
|
|
64
75
|
appId: string
|
|
76
|
+
botName?: string
|
|
65
77
|
}): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
|
|
66
78
|
const baseName = path.basename(projectDirectory)
|
|
67
79
|
const channelName = `${baseName}`
|
|
@@ -69,8 +81,8 @@ export async function createProjectChannels({
|
|
|
69
81
|
.replace(/[^a-z0-9-]/g, '-')
|
|
70
82
|
.slice(0, 100)
|
|
71
83
|
|
|
72
|
-
const kimakiCategory = await ensureKimakiCategory(guild)
|
|
73
|
-
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild)
|
|
84
|
+
const kimakiCategory = await ensureKimakiCategory(guild, botName)
|
|
85
|
+
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
|
|
74
86
|
|
|
75
87
|
const textChannel = await guild.channels.create({
|
|
76
88
|
name: channelName,
|
package/src/cli.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
createProjectChannels,
|
|
28
28
|
type ChannelWithTags,
|
|
29
29
|
} from './discord-bot.js'
|
|
30
|
-
import type { OpencodeClient } from '@opencode-ai/sdk'
|
|
30
|
+
import type { OpencodeClient, Command as OpencodeCommand } from '@opencode-ai/sdk'
|
|
31
31
|
import {
|
|
32
32
|
Events,
|
|
33
33
|
ChannelType,
|
|
@@ -82,13 +82,14 @@ async function killProcessOnPort(port: number): Promise<boolean> {
|
|
|
82
82
|
// Filter out our own PID and take the first (oldest)
|
|
83
83
|
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
|
|
84
84
|
if (targetPid) {
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
const pid = parseInt(targetPid, 10)
|
|
86
|
+
cliLogger.log(`Stopping existing kimaki process (PID: ${pid})`)
|
|
87
|
+
process.kill(pid, 'SIGKILL')
|
|
87
88
|
return true
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
|
-
} catch {
|
|
91
|
-
|
|
91
|
+
} catch (e) {
|
|
92
|
+
cliLogger.debug(`Failed to kill process on port ${port}:`, e)
|
|
92
93
|
}
|
|
93
94
|
return false
|
|
94
95
|
}
|
|
@@ -105,7 +106,7 @@ async function checkSingleInstance(): Promise<void> {
|
|
|
105
106
|
await new Promise((resolve) => { setTimeout(resolve, 500) })
|
|
106
107
|
}
|
|
107
108
|
} catch {
|
|
108
|
-
|
|
109
|
+
cliLogger.debug('No other kimaki instance detected on lock port')
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
@@ -152,7 +153,10 @@ type CliOptions = {
|
|
|
152
153
|
addChannels?: boolean
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
|
|
156
|
+
// Commands to skip when registering user commands (reserved names)
|
|
157
|
+
const SKIP_USER_COMMANDS = ['init']
|
|
158
|
+
|
|
159
|
+
async function registerCommands(token: string, appId: string, userCommands: OpencodeCommand[] = []) {
|
|
156
160
|
const commands = [
|
|
157
161
|
new SlashCommandBuilder()
|
|
158
162
|
.setName('resume')
|
|
@@ -216,19 +220,11 @@ async function registerCommands(token: string, appId: string) {
|
|
|
216
220
|
})
|
|
217
221
|
.toJSON(),
|
|
218
222
|
new SlashCommandBuilder()
|
|
219
|
-
.setName('
|
|
220
|
-
.setDescription('
|
|
221
|
-
.toJSON(),
|
|
222
|
-
new SlashCommandBuilder()
|
|
223
|
-
.setName('accept-always')
|
|
224
|
-
.setDescription('Accept and auto-approve future requests matching this pattern')
|
|
225
|
-
.toJSON(),
|
|
226
|
-
new SlashCommandBuilder()
|
|
227
|
-
.setName('reject')
|
|
228
|
-
.setDescription('Reject a pending permission request')
|
|
223
|
+
.setName('abort')
|
|
224
|
+
.setDescription('Abort the current OpenCode request in this thread')
|
|
229
225
|
.toJSON(),
|
|
230
226
|
new SlashCommandBuilder()
|
|
231
|
-
.setName('
|
|
227
|
+
.setName('stop')
|
|
232
228
|
.setDescription('Abort the current OpenCode request in this thread')
|
|
233
229
|
.toJSON(),
|
|
234
230
|
new SlashCommandBuilder()
|
|
@@ -243,6 +239,10 @@ async function registerCommands(token: string, appId: string) {
|
|
|
243
239
|
.setName('model')
|
|
244
240
|
.setDescription('Set the preferred model for this channel or session')
|
|
245
241
|
.toJSON(),
|
|
242
|
+
new SlashCommandBuilder()
|
|
243
|
+
.setName('agent')
|
|
244
|
+
.setDescription('Set the preferred agent for this channel or session')
|
|
245
|
+
.toJSON(),
|
|
246
246
|
new SlashCommandBuilder()
|
|
247
247
|
.setName('queue')
|
|
248
248
|
.setDescription('Queue a message to be sent after the current response finishes')
|
|
@@ -269,6 +269,30 @@ async function registerCommands(token: string, appId: string) {
|
|
|
269
269
|
.toJSON(),
|
|
270
270
|
]
|
|
271
271
|
|
|
272
|
+
// Add user-defined commands with -cmd suffix
|
|
273
|
+
for (const cmd of userCommands) {
|
|
274
|
+
if (SKIP_USER_COMMANDS.includes(cmd.name)) {
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const commandName = `${cmd.name}-cmd`
|
|
279
|
+
const description = cmd.description || `Run /${cmd.name} command`
|
|
280
|
+
|
|
281
|
+
commands.push(
|
|
282
|
+
new SlashCommandBuilder()
|
|
283
|
+
.setName(commandName)
|
|
284
|
+
.setDescription(description.slice(0, 100)) // Discord limits to 100 chars
|
|
285
|
+
.addStringOption((option) => {
|
|
286
|
+
option
|
|
287
|
+
.setName('arguments')
|
|
288
|
+
.setDescription('Arguments to pass to the command')
|
|
289
|
+
.setRequired(false)
|
|
290
|
+
return option
|
|
291
|
+
})
|
|
292
|
+
.toJSON(),
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
272
296
|
const rest = new REST().setToken(token)
|
|
273
297
|
|
|
274
298
|
try {
|
|
@@ -686,6 +710,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
686
710
|
guild: targetGuild,
|
|
687
711
|
projectDirectory: project.worktree,
|
|
688
712
|
appId,
|
|
713
|
+
botName: discordClient.user?.username,
|
|
689
714
|
})
|
|
690
715
|
|
|
691
716
|
createdChannels.push({
|
|
@@ -709,8 +734,37 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
709
734
|
}
|
|
710
735
|
}
|
|
711
736
|
|
|
737
|
+
// Fetch user-defined commands using the already-running server
|
|
738
|
+
const allUserCommands: OpencodeCommand[] = []
|
|
739
|
+
try {
|
|
740
|
+
const commandsResponse = await getClient().command.list({
|
|
741
|
+
query: { directory: currentDir },
|
|
742
|
+
})
|
|
743
|
+
if (commandsResponse.data) {
|
|
744
|
+
allUserCommands.push(...commandsResponse.data)
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
// Ignore errors fetching commands
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Log available user commands
|
|
751
|
+
const registrableCommands = allUserCommands.filter(
|
|
752
|
+
(cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
if (registrableCommands.length > 0) {
|
|
756
|
+
const commandList = registrableCommands
|
|
757
|
+
.map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
|
|
758
|
+
.join('\n')
|
|
759
|
+
|
|
760
|
+
note(
|
|
761
|
+
`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`,
|
|
762
|
+
'OpenCode Commands',
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
|
|
712
766
|
cliLogger.log('Registering slash commands asynchronously...')
|
|
713
|
-
void registerCommands(token, appId)
|
|
767
|
+
void registerCommands(token, appId, allUserCommands)
|
|
714
768
|
.then(() => {
|
|
715
769
|
cliLogger.log('Slash commands registered!')
|
|
716
770
|
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// /agent command - Set the preferred agent for this channel or session.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ChatInputCommandInteraction,
|
|
5
|
+
StringSelectMenuInteraction,
|
|
6
|
+
StringSelectMenuBuilder,
|
|
7
|
+
ActionRowBuilder,
|
|
8
|
+
ChannelType,
|
|
9
|
+
type ThreadChannel,
|
|
10
|
+
type TextChannel,
|
|
11
|
+
} from 'discord.js'
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js'
|
|
14
|
+
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
15
|
+
import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js'
|
|
16
|
+
import { createLogger } from '../logger.js'
|
|
17
|
+
|
|
18
|
+
const agentLogger = createLogger('AGENT')
|
|
19
|
+
|
|
20
|
+
const pendingAgentContexts = new Map<string, {
|
|
21
|
+
dir: string
|
|
22
|
+
channelId: string
|
|
23
|
+
sessionId?: string
|
|
24
|
+
isThread: boolean
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
export async function handleAgentCommand({
|
|
28
|
+
interaction,
|
|
29
|
+
appId,
|
|
30
|
+
}: {
|
|
31
|
+
interaction: ChatInputCommandInteraction
|
|
32
|
+
appId: string
|
|
33
|
+
}): Promise<void> {
|
|
34
|
+
await interaction.deferReply({ ephemeral: true })
|
|
35
|
+
|
|
36
|
+
runModelMigrations()
|
|
37
|
+
|
|
38
|
+
const channel = interaction.channel
|
|
39
|
+
|
|
40
|
+
if (!channel) {
|
|
41
|
+
await interaction.editReply({ content: 'This command can only be used in a channel' })
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isThread = [
|
|
46
|
+
ChannelType.PublicThread,
|
|
47
|
+
ChannelType.PrivateThread,
|
|
48
|
+
ChannelType.AnnouncementThread,
|
|
49
|
+
].includes(channel.type)
|
|
50
|
+
|
|
51
|
+
let projectDirectory: string | undefined
|
|
52
|
+
let channelAppId: string | undefined
|
|
53
|
+
let targetChannelId: string
|
|
54
|
+
let sessionId: string | undefined
|
|
55
|
+
|
|
56
|
+
if (isThread) {
|
|
57
|
+
const thread = channel as ThreadChannel
|
|
58
|
+
const textChannel = await resolveTextChannel(thread)
|
|
59
|
+
const metadata = getKimakiMetadata(textChannel)
|
|
60
|
+
projectDirectory = metadata.projectDirectory
|
|
61
|
+
channelAppId = metadata.channelAppId
|
|
62
|
+
targetChannelId = textChannel?.id || channel.id
|
|
63
|
+
|
|
64
|
+
const row = getDatabase()
|
|
65
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
66
|
+
.get(thread.id) as { session_id: string } | undefined
|
|
67
|
+
sessionId = row?.session_id
|
|
68
|
+
} else if (channel.type === ChannelType.GuildText) {
|
|
69
|
+
const textChannel = channel as TextChannel
|
|
70
|
+
const metadata = getKimakiMetadata(textChannel)
|
|
71
|
+
projectDirectory = metadata.projectDirectory
|
|
72
|
+
channelAppId = metadata.channelAppId
|
|
73
|
+
targetChannelId = channel.id
|
|
74
|
+
} else {
|
|
75
|
+
await interaction.editReply({ content: 'This command can only be used in text channels or threads' })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (channelAppId && channelAppId !== appId) {
|
|
80
|
+
await interaction.editReply({ content: 'This channel is not configured for this bot' })
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!projectDirectory) {
|
|
85
|
+
await interaction.editReply({ content: 'This channel is not configured with a project directory' })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory)
|
|
91
|
+
|
|
92
|
+
const agentsResponse = await getClient().app.agents({
|
|
93
|
+
query: { directory: projectDirectory },
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!agentsResponse.data || agentsResponse.data.length === 0) {
|
|
97
|
+
await interaction.editReply({ content: 'No agents available' })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const agents = agentsResponse.data
|
|
102
|
+
.filter((a) => a.mode === 'primary' || a.mode === 'all')
|
|
103
|
+
.slice(0, 25)
|
|
104
|
+
|
|
105
|
+
if (agents.length === 0) {
|
|
106
|
+
await interaction.editReply({ content: 'No primary agents available' })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const contextHash = crypto.randomBytes(8).toString('hex')
|
|
111
|
+
pendingAgentContexts.set(contextHash, {
|
|
112
|
+
dir: projectDirectory,
|
|
113
|
+
channelId: targetChannelId,
|
|
114
|
+
sessionId,
|
|
115
|
+
isThread,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const options = agents.map((agent) => ({
|
|
119
|
+
label: agent.name.slice(0, 100),
|
|
120
|
+
value: agent.name,
|
|
121
|
+
description: (agent.description || `${agent.mode} agent`).slice(0, 100),
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
125
|
+
.setCustomId(`agent_select:${contextHash}`)
|
|
126
|
+
.setPlaceholder('Select an agent')
|
|
127
|
+
.addOptions(options)
|
|
128
|
+
|
|
129
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(selectMenu)
|
|
130
|
+
|
|
131
|
+
await interaction.editReply({
|
|
132
|
+
content: '**Set Agent Preference**\nSelect an agent:',
|
|
133
|
+
components: [actionRow],
|
|
134
|
+
})
|
|
135
|
+
} catch (error) {
|
|
136
|
+
agentLogger.error('Error loading agents:', error)
|
|
137
|
+
await interaction.editReply({
|
|
138
|
+
content: `Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function handleAgentSelectMenu(
|
|
144
|
+
interaction: StringSelectMenuInteraction
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const customId = interaction.customId
|
|
147
|
+
|
|
148
|
+
if (!customId.startsWith('agent_select:')) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await interaction.deferUpdate()
|
|
153
|
+
|
|
154
|
+
const contextHash = customId.replace('agent_select:', '')
|
|
155
|
+
const context = pendingAgentContexts.get(contextHash)
|
|
156
|
+
|
|
157
|
+
if (!context) {
|
|
158
|
+
await interaction.editReply({
|
|
159
|
+
content: 'Selection expired. Please run /agent again.',
|
|
160
|
+
components: [],
|
|
161
|
+
})
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const selectedAgent = interaction.values[0]
|
|
166
|
+
if (!selectedAgent) {
|
|
167
|
+
await interaction.editReply({
|
|
168
|
+
content: 'No agent selected',
|
|
169
|
+
components: [],
|
|
170
|
+
})
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
if (context.isThread && context.sessionId) {
|
|
176
|
+
setSessionAgent(context.sessionId, selectedAgent)
|
|
177
|
+
agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`)
|
|
178
|
+
|
|
179
|
+
await interaction.editReply({
|
|
180
|
+
content: `Agent preference set for this session: **${selectedAgent}**`,
|
|
181
|
+
components: [],
|
|
182
|
+
})
|
|
183
|
+
} else {
|
|
184
|
+
setChannelAgent(context.channelId, selectedAgent)
|
|
185
|
+
agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`)
|
|
186
|
+
|
|
187
|
+
await interaction.editReply({
|
|
188
|
+
content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
|
|
189
|
+
components: [],
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
pendingAgentContexts.delete(contextHash)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
agentLogger.error('Error saving agent preference:', error)
|
|
196
|
+
await interaction.editReply({
|
|
197
|
+
content: `Failed to save agent preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
198
|
+
components: [],
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|