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/dist/voice.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Audio transcription service using Google Gemini.
|
|
2
|
+
// Transcribes voice messages with code-aware context, using grep/glob tools
|
|
3
|
+
// to verify technical terms, filenames, and function names in the codebase.
|
|
1
4
|
import { GoogleGenAI, Type, } from '@google/genai';
|
|
2
5
|
import { createLogger } from './logger.js';
|
|
3
6
|
import { glob } from 'glob';
|
|
@@ -20,7 +23,8 @@ async function runGrep({ pattern, directory, }) {
|
|
|
20
23
|
.join('\n');
|
|
21
24
|
return output.slice(0, 2000);
|
|
22
25
|
}
|
|
23
|
-
catch {
|
|
26
|
+
catch (e) {
|
|
27
|
+
voiceLogger.error('grep search failed:', e);
|
|
24
28
|
return 'grep search failed';
|
|
25
29
|
}
|
|
26
30
|
}
|
|
@@ -201,7 +205,7 @@ export async function runTranscriptionLoop({ genAI, model, initialContents, tool
|
|
|
201
205
|
});
|
|
202
206
|
}
|
|
203
207
|
}
|
|
204
|
-
export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory,
|
|
208
|
+
export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, currentSessionContext, lastSessionContext, }) {
|
|
205
209
|
try {
|
|
206
210
|
const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
|
|
207
211
|
if (!apiKey) {
|
|
@@ -225,6 +229,21 @@ export async function transcribeAudio({ audio, prompt, language, temperature, ge
|
|
|
225
229
|
throw new Error('Invalid audio format');
|
|
226
230
|
}
|
|
227
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
|
+
: '';
|
|
228
247
|
const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
|
|
229
248
|
|
|
230
249
|
CRITICAL REQUIREMENT: You MUST call the "transcriptionResult" tool to complete this task.
|
|
@@ -238,29 +257,29 @@ This is a software development environment. The speaker is giving instructions t
|
|
|
238
257
|
- File paths, function names, CLI commands, package names, API endpoints
|
|
239
258
|
|
|
240
259
|
RULES:
|
|
241
|
-
1.
|
|
242
|
-
2. If audio
|
|
243
|
-
3.
|
|
244
|
-
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
|
|
245
263
|
|
|
246
264
|
Common corrections (apply without tool calls):
|
|
247
265
|
- "reacked" → "React", "jason" → "JSON", "get hub" → "GitHub", "no JS" → "Node.js", "dacker" → "Docker"
|
|
248
266
|
|
|
249
|
-
Project
|
|
250
|
-
<
|
|
267
|
+
Project file structure:
|
|
268
|
+
<file_tree>
|
|
251
269
|
${prompt}
|
|
252
|
-
</
|
|
253
|
-
${
|
|
270
|
+
</file_tree>
|
|
271
|
+
${sessionContextSection}
|
|
254
272
|
|
|
255
273
|
REMEMBER: Call "transcriptionResult" tool with your transcription. This is mandatory.
|
|
256
274
|
|
|
257
275
|
Note: "critique" is a CLI tool for showing diffs in the browser.`;
|
|
258
|
-
const hasDirectory = directory && directory.trim().length > 0
|
|
276
|
+
// const hasDirectory = directory && directory.trim().length > 0
|
|
259
277
|
const tools = [
|
|
260
278
|
{
|
|
261
279
|
functionDeclarations: [
|
|
262
280
|
transcriptionResultToolDeclaration,
|
|
263
|
-
|
|
281
|
+
// grep/glob disabled - was causing transcription to hang
|
|
282
|
+
// ...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
|
|
264
283
|
],
|
|
265
284
|
},
|
|
266
285
|
];
|
package/dist/worker-types.js
CHANGED
package/dist/xml.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// XML/HTML tag content extractor.
|
|
2
|
+
// Parses XML-like tags from strings (e.g., channel topics) to extract
|
|
3
|
+
// Kimaki configuration like directory paths and app IDs.
|
|
1
4
|
import { DomHandler, Parser, ElementType } from 'htmlparser2';
|
|
2
5
|
import { createLogger } from './logger.js';
|
|
3
6
|
const xmlLogger = createLogger('XML');
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.26",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
8
|
"prepublishOnly": "pnpm tsc",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"bin.js"
|
|
22
22
|
],
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@opencode-ai/plugin": "^1.
|
|
24
|
+
"@opencode-ai/plugin": "^1.1.12",
|
|
25
25
|
"@types/better-sqlite3": "^7.6.13",
|
|
26
26
|
"@types/bun": "latest",
|
|
27
27
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@discordjs/opus": "^0.10.0",
|
|
36
36
|
"@discordjs/voice": "^0.19.0",
|
|
37
37
|
"@google/genai": "^1.34.0",
|
|
38
|
-
"@opencode-ai/sdk": "^1.
|
|
38
|
+
"@opencode-ai/sdk": "^1.1.12",
|
|
39
39
|
"@purinton/resampler": "^1.0.4",
|
|
40
40
|
"@snazzah/davey": "^0.1.6",
|
|
41
41
|
"ai": "^5.0.114",
|
|
@@ -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
|
package/src/ai-tool-to-genai.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
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.
|
|
4
|
+
|
|
1
5
|
import type { Tool, jsonSchema as JsonSchemaType } from 'ai'
|
|
2
6
|
import type {
|
|
3
7
|
FunctionDeclaration,
|
|
@@ -1,3 +1,7 @@
|
|
|
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.
|
|
4
|
+
|
|
1
5
|
import {
|
|
2
6
|
ChannelType,
|
|
3
7
|
type CategoryChannel,
|
|
@@ -8,14 +12,19 @@ import path from 'node:path'
|
|
|
8
12
|
import { getDatabase } from './database.js'
|
|
9
13
|
import { extractTagsArrays } from './xml.js'
|
|
10
14
|
|
|
11
|
-
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
|
+
|
|
12
21
|
const existingCategory = guild.channels.cache.find(
|
|
13
22
|
(channel): channel is CategoryChannel => {
|
|
14
23
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
15
24
|
return false
|
|
16
25
|
}
|
|
17
26
|
|
|
18
|
-
return channel.name.toLowerCase() ===
|
|
27
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase()
|
|
19
28
|
},
|
|
20
29
|
)
|
|
21
30
|
|
|
@@ -24,19 +33,24 @@ export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChanne
|
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
return guild.channels.create({
|
|
27
|
-
name:
|
|
36
|
+
name: categoryName,
|
|
28
37
|
type: ChannelType.GuildCategory,
|
|
29
38
|
})
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
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
|
+
|
|
33
47
|
const existingCategory = guild.channels.cache.find(
|
|
34
48
|
(channel): channel is CategoryChannel => {
|
|
35
49
|
if (channel.type !== ChannelType.GuildCategory) {
|
|
36
50
|
return false
|
|
37
51
|
}
|
|
38
52
|
|
|
39
|
-
return channel.name.toLowerCase() ===
|
|
53
|
+
return channel.name.toLowerCase() === categoryName.toLowerCase()
|
|
40
54
|
},
|
|
41
55
|
)
|
|
42
56
|
|
|
@@ -45,7 +59,7 @@ export async function ensureKimakiAudioCategory(guild: Guild): Promise<CategoryC
|
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
return guild.channels.create({
|
|
48
|
-
name:
|
|
62
|
+
name: categoryName,
|
|
49
63
|
type: ChannelType.GuildCategory,
|
|
50
64
|
})
|
|
51
65
|
}
|
|
@@ -54,10 +68,12 @@ export async function createProjectChannels({
|
|
|
54
68
|
guild,
|
|
55
69
|
projectDirectory,
|
|
56
70
|
appId,
|
|
71
|
+
botName,
|
|
57
72
|
}: {
|
|
58
73
|
guild: Guild
|
|
59
74
|
projectDirectory: string
|
|
60
75
|
appId: string
|
|
76
|
+
botName?: string
|
|
61
77
|
}): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
|
|
62
78
|
const baseName = path.basename(projectDirectory)
|
|
63
79
|
const channelName = `${baseName}`
|
|
@@ -65,8 +81,8 @@ export async function createProjectChannels({
|
|
|
65
81
|
.replace(/[^a-z0-9-]/g, '-')
|
|
66
82
|
.slice(0, 100)
|
|
67
83
|
|
|
68
|
-
const kimakiCategory = await ensureKimakiCategory(guild)
|
|
69
|
-
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild)
|
|
84
|
+
const kimakiCategory = await ensureKimakiCategory(guild, botName)
|
|
85
|
+
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
|
|
70
86
|
|
|
71
87
|
const textChannel = await guild.channels.create({
|
|
72
88
|
name: channelName,
|
package/src/cli.ts
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 {
|
|
4
7
|
intro,
|
|
@@ -24,7 +27,7 @@ import {
|
|
|
24
27
|
createProjectChannels,
|
|
25
28
|
type ChannelWithTags,
|
|
26
29
|
} from './discord-bot.js'
|
|
27
|
-
import type { OpencodeClient } from '@opencode-ai/sdk'
|
|
30
|
+
import type { OpencodeClient, Command as OpencodeCommand } from '@opencode-ai/sdk'
|
|
28
31
|
import {
|
|
29
32
|
Events,
|
|
30
33
|
ChannelType,
|
|
@@ -50,31 +53,84 @@ process.title = 'kimaki'
|
|
|
50
53
|
|
|
51
54
|
const LOCK_PORT = 29988
|
|
52
55
|
|
|
56
|
+
async function killProcessOnPort(port: number): Promise<boolean> {
|
|
57
|
+
const isWindows = process.platform === 'win32'
|
|
58
|
+
const myPid = process.pid
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (isWindows) {
|
|
62
|
+
// Windows: find PID using netstat, then kill
|
|
63
|
+
const result = spawnSync('cmd', ['/c', `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`], {
|
|
64
|
+
shell: false,
|
|
65
|
+
encoding: 'utf-8',
|
|
66
|
+
})
|
|
67
|
+
const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p))
|
|
68
|
+
// Filter out our own PID and take the first (oldest)
|
|
69
|
+
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
|
|
70
|
+
if (targetPid) {
|
|
71
|
+
cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`)
|
|
72
|
+
spawnSync('taskkill', ['/F', '/PID', targetPid], { shell: false })
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
// Unix: use lsof with -sTCP:LISTEN to only find the listening process
|
|
77
|
+
const result = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t'], {
|
|
78
|
+
shell: false,
|
|
79
|
+
encoding: 'utf-8',
|
|
80
|
+
})
|
|
81
|
+
const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p))
|
|
82
|
+
// Filter out our own PID and take the first (oldest)
|
|
83
|
+
const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
|
|
84
|
+
if (targetPid) {
|
|
85
|
+
const pid = parseInt(targetPid, 10)
|
|
86
|
+
cliLogger.log(`Stopping existing kimaki process (PID: ${pid})`)
|
|
87
|
+
process.kill(pid, 'SIGKILL')
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (e) {
|
|
92
|
+
cliLogger.debug(`Failed to kill process on port ${port}:`, e)
|
|
93
|
+
}
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
|
|
53
97
|
async function checkSingleInstance(): Promise<void> {
|
|
54
98
|
try {
|
|
55
99
|
const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
|
|
56
100
|
signal: AbortSignal.timeout(1000),
|
|
57
101
|
})
|
|
58
102
|
if (response.ok) {
|
|
59
|
-
cliLogger.
|
|
60
|
-
|
|
103
|
+
cliLogger.log('Another kimaki instance detected')
|
|
104
|
+
await killProcessOnPort(LOCK_PORT)
|
|
105
|
+
// Wait a moment for port to be released
|
|
106
|
+
await new Promise((resolve) => { setTimeout(resolve, 500) })
|
|
61
107
|
}
|
|
62
108
|
} catch {
|
|
63
|
-
|
|
109
|
+
cliLogger.debug('No other kimaki instance detected on lock port')
|
|
64
110
|
}
|
|
65
111
|
}
|
|
66
112
|
|
|
67
|
-
function startLockServer(): void {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
113
|
+
async function startLockServer(): Promise<void> {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const server = http.createServer((req, res) => {
|
|
116
|
+
res.writeHead(200)
|
|
117
|
+
res.end('kimaki')
|
|
118
|
+
})
|
|
119
|
+
server.listen(LOCK_PORT, '127.0.0.1')
|
|
120
|
+
server.once('listening', () => {
|
|
121
|
+
resolve()
|
|
122
|
+
})
|
|
123
|
+
server.on('error', async (err: NodeJS.ErrnoException) => {
|
|
124
|
+
if (err.code === 'EADDRINUSE') {
|
|
125
|
+
cliLogger.log('Port still in use, retrying...')
|
|
126
|
+
await killProcessOnPort(LOCK_PORT)
|
|
127
|
+
await new Promise((r) => { setTimeout(r, 500) })
|
|
128
|
+
// Retry once
|
|
129
|
+
server.listen(LOCK_PORT, '127.0.0.1')
|
|
130
|
+
} else {
|
|
131
|
+
reject(err)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
78
134
|
})
|
|
79
135
|
}
|
|
80
136
|
|
|
@@ -97,7 +153,10 @@ type CliOptions = {
|
|
|
97
153
|
addChannels?: boolean
|
|
98
154
|
}
|
|
99
155
|
|
|
100
|
-
|
|
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[] = []) {
|
|
101
160
|
const commands = [
|
|
102
161
|
new SlashCommandBuilder()
|
|
103
162
|
.setName('resume')
|
|
@@ -176,6 +235,10 @@ async function registerCommands(token: string, appId: string) {
|
|
|
176
235
|
.setName('abort')
|
|
177
236
|
.setDescription('Abort the current OpenCode request in this thread')
|
|
178
237
|
.toJSON(),
|
|
238
|
+
new SlashCommandBuilder()
|
|
239
|
+
.setName('stop')
|
|
240
|
+
.setDescription('Abort the current OpenCode request in this thread')
|
|
241
|
+
.toJSON(),
|
|
179
242
|
new SlashCommandBuilder()
|
|
180
243
|
.setName('share')
|
|
181
244
|
.setDescription('Share the current session as a public URL')
|
|
@@ -188,8 +251,60 @@ async function registerCommands(token: string, appId: string) {
|
|
|
188
251
|
.setName('model')
|
|
189
252
|
.setDescription('Set the preferred model for this channel or session')
|
|
190
253
|
.toJSON(),
|
|
254
|
+
new SlashCommandBuilder()
|
|
255
|
+
.setName('agent')
|
|
256
|
+
.setDescription('Set the preferred agent for this channel or session')
|
|
257
|
+
.toJSON(),
|
|
258
|
+
new SlashCommandBuilder()
|
|
259
|
+
.setName('queue')
|
|
260
|
+
.setDescription('Queue a message to be sent after the current response finishes')
|
|
261
|
+
.addStringOption((option) => {
|
|
262
|
+
option
|
|
263
|
+
.setName('message')
|
|
264
|
+
.setDescription('The message to queue')
|
|
265
|
+
.setRequired(true)
|
|
266
|
+
|
|
267
|
+
return option
|
|
268
|
+
})
|
|
269
|
+
.toJSON(),
|
|
270
|
+
new SlashCommandBuilder()
|
|
271
|
+
.setName('clear-queue')
|
|
272
|
+
.setDescription('Clear all queued messages in this thread')
|
|
273
|
+
.toJSON(),
|
|
274
|
+
new SlashCommandBuilder()
|
|
275
|
+
.setName('undo')
|
|
276
|
+
.setDescription('Undo the last assistant message (revert file changes)')
|
|
277
|
+
.toJSON(),
|
|
278
|
+
new SlashCommandBuilder()
|
|
279
|
+
.setName('redo')
|
|
280
|
+
.setDescription('Redo previously undone changes')
|
|
281
|
+
.toJSON(),
|
|
191
282
|
]
|
|
192
283
|
|
|
284
|
+
// Add user-defined commands with -cmd suffix
|
|
285
|
+
for (const cmd of userCommands) {
|
|
286
|
+
if (SKIP_USER_COMMANDS.includes(cmd.name)) {
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const commandName = `${cmd.name}-cmd`
|
|
291
|
+
const description = cmd.description || `Run /${cmd.name} command`
|
|
292
|
+
|
|
293
|
+
commands.push(
|
|
294
|
+
new SlashCommandBuilder()
|
|
295
|
+
.setName(commandName)
|
|
296
|
+
.setDescription(description.slice(0, 100)) // Discord limits to 100 chars
|
|
297
|
+
.addStringOption((option) => {
|
|
298
|
+
option
|
|
299
|
+
.setName('arguments')
|
|
300
|
+
.setDescription('Arguments to pass to the command')
|
|
301
|
+
.setRequired(false)
|
|
302
|
+
return option
|
|
303
|
+
})
|
|
304
|
+
.toJSON(),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
193
308
|
const rest = new REST().setToken(token)
|
|
194
309
|
|
|
195
310
|
try {
|
|
@@ -607,6 +722,7 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
607
722
|
guild: targetGuild,
|
|
608
723
|
projectDirectory: project.worktree,
|
|
609
724
|
appId,
|
|
725
|
+
botName: discordClient.user?.username,
|
|
610
726
|
})
|
|
611
727
|
|
|
612
728
|
createdChannels.push({
|
|
@@ -630,8 +746,37 @@ async function run({ restart, addChannels }: CliOptions) {
|
|
|
630
746
|
}
|
|
631
747
|
}
|
|
632
748
|
|
|
749
|
+
// Fetch user-defined commands using the already-running server
|
|
750
|
+
const allUserCommands: OpencodeCommand[] = []
|
|
751
|
+
try {
|
|
752
|
+
const commandsResponse = await getClient().command.list({
|
|
753
|
+
query: { directory: currentDir },
|
|
754
|
+
})
|
|
755
|
+
if (commandsResponse.data) {
|
|
756
|
+
allUserCommands.push(...commandsResponse.data)
|
|
757
|
+
}
|
|
758
|
+
} catch {
|
|
759
|
+
// Ignore errors fetching commands
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Log available user commands
|
|
763
|
+
const registrableCommands = allUserCommands.filter(
|
|
764
|
+
(cmd) => !SKIP_USER_COMMANDS.includes(cmd.name),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
if (registrableCommands.length > 0) {
|
|
768
|
+
const commandList = registrableCommands
|
|
769
|
+
.map((cmd) => ` /${cmd.name}-cmd - ${cmd.description || 'No description'}`)
|
|
770
|
+
.join('\n')
|
|
771
|
+
|
|
772
|
+
note(
|
|
773
|
+
`Found ${registrableCommands.length} user-defined command(s):\n${commandList}`,
|
|
774
|
+
'OpenCode Commands',
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
|
|
633
778
|
cliLogger.log('Registering slash commands asynchronously...')
|
|
634
|
-
void registerCommands(token, appId)
|
|
779
|
+
void registerCommands(token, appId, allUserCommands)
|
|
635
780
|
.then(() => {
|
|
636
781
|
cliLogger.log('Slash commands registered!')
|
|
637
782
|
})
|
|
@@ -693,7 +838,7 @@ cli
|
|
|
693
838
|
.action(async (options: { restart?: boolean; addChannels?: boolean }) => {
|
|
694
839
|
try {
|
|
695
840
|
await checkSingleInstance()
|
|
696
|
-
startLockServer()
|
|
841
|
+
await startLockServer()
|
|
697
842
|
await run({
|
|
698
843
|
restart: options.restart,
|
|
699
844
|
addChannels: options.addChannels,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// /abort command - Abort the current OpenCode request in this thread.
|
|
2
|
+
|
|
3
|
+
import { ChannelType, type ThreadChannel } from 'discord.js'
|
|
4
|
+
import type { CommandContext } from './types.js'
|
|
5
|
+
import { getDatabase } from '../database.js'
|
|
6
|
+
import { initializeOpencodeForDirectory } from '../opencode.js'
|
|
7
|
+
import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
8
|
+
import { abortControllers } from '../session-handler.js'
|
|
9
|
+
import { createLogger } from '../logger.js'
|
|
10
|
+
|
|
11
|
+
const logger = createLogger('ABORT')
|
|
12
|
+
|
|
13
|
+
export async function handleAbortCommand({
|
|
14
|
+
command,
|
|
15
|
+
}: CommandContext): Promise<void> {
|
|
16
|
+
const channel = command.channel
|
|
17
|
+
|
|
18
|
+
if (!channel) {
|
|
19
|
+
await command.reply({
|
|
20
|
+
content: 'This command can only be used in a channel',
|
|
21
|
+
ephemeral: true,
|
|
22
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
23
|
+
})
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isThread = [
|
|
28
|
+
ChannelType.PublicThread,
|
|
29
|
+
ChannelType.PrivateThread,
|
|
30
|
+
ChannelType.AnnouncementThread,
|
|
31
|
+
].includes(channel.type)
|
|
32
|
+
|
|
33
|
+
if (!isThread) {
|
|
34
|
+
await command.reply({
|
|
35
|
+
content: 'This command can only be used in a thread with an active session',
|
|
36
|
+
ephemeral: true,
|
|
37
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
38
|
+
})
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const textChannel = await resolveTextChannel(channel as ThreadChannel)
|
|
43
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
44
|
+
|
|
45
|
+
if (!directory) {
|
|
46
|
+
await command.reply({
|
|
47
|
+
content: 'Could not determine project directory for this channel',
|
|
48
|
+
ephemeral: true,
|
|
49
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
50
|
+
})
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const row = getDatabase()
|
|
55
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
56
|
+
.get(channel.id) as { session_id: string } | undefined
|
|
57
|
+
|
|
58
|
+
if (!row?.session_id) {
|
|
59
|
+
await command.reply({
|
|
60
|
+
content: 'No active session in this thread',
|
|
61
|
+
ephemeral: true,
|
|
62
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
63
|
+
})
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sessionId = row.session_id
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const existingController = abortControllers.get(sessionId)
|
|
71
|
+
if (existingController) {
|
|
72
|
+
existingController.abort(new Error('User requested abort'))
|
|
73
|
+
abortControllers.delete(sessionId)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
77
|
+
await getClient().session.abort({
|
|
78
|
+
path: { id: sessionId },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await command.reply({
|
|
82
|
+
content: `🛑 Request **aborted**`,
|
|
83
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
84
|
+
})
|
|
85
|
+
logger.log(`Session ${sessionId} aborted by user`)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
logger.error('[ABORT] Error:', error)
|
|
88
|
+
await command.reply({
|
|
89
|
+
content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
90
|
+
ephemeral: true,
|
|
91
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|