kimaki 0.4.22 → 0.4.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel-management.js +92 -0
- package/dist/cli.js +9 -1
- package/dist/database.js +130 -0
- package/dist/discord-bot.js +381 -0
- package/dist/discord-utils.js +151 -0
- package/dist/escape-backticks.test.js +1 -1
- package/dist/fork.js +163 -0
- package/dist/interaction-handler.js +750 -0
- package/dist/message-formatting.js +188 -0
- package/dist/model-command.js +293 -0
- package/dist/opencode.js +135 -0
- package/dist/session-handler.js +467 -0
- package/dist/system-message.js +92 -0
- package/dist/tools.js +1 -1
- package/dist/voice-handler.js +528 -0
- package/dist/voice.js +257 -35
- package/package.json +3 -1
- package/src/channel-management.ts +145 -0
- package/src/cli.ts +9 -1
- package/src/database.ts +155 -0
- package/src/discord-bot.ts +506 -0
- package/src/discord-utils.ts +208 -0
- package/src/escape-backticks.test.ts +1 -1
- package/src/fork.ts +224 -0
- package/src/interaction-handler.ts +1000 -0
- package/src/message-formatting.ts +227 -0
- package/src/model-command.ts +380 -0
- package/src/opencode.ts +180 -0
- package/src/session-handler.ts +601 -0
- package/src/system-message.ts +92 -0
- package/src/tools.ts +1 -1
- package/src/voice-handler.ts +745 -0
- package/src/voice.ts +354 -36
- package/src/discordBot.ts +0 -3671
package/dist/voice.js
CHANGED
|
@@ -1,16 +1,213 @@
|
|
|
1
|
-
import { GoogleGenAI } from '@google/genai';
|
|
1
|
+
import { GoogleGenAI, Type, } from '@google/genai';
|
|
2
2
|
import { createLogger } from './logger.js';
|
|
3
|
+
import { glob } from 'glob';
|
|
4
|
+
import { ripGrep } from 'ripgrep-js';
|
|
3
5
|
const voiceLogger = createLogger('VOICE');
|
|
4
|
-
|
|
6
|
+
async function runGrep({ pattern, directory, }) {
|
|
7
|
+
try {
|
|
8
|
+
const results = await ripGrep(directory, {
|
|
9
|
+
string: pattern,
|
|
10
|
+
globs: ['!node_modules/**', '!.git/**', '!dist/**', '!build/**'],
|
|
11
|
+
});
|
|
12
|
+
if (results.length === 0) {
|
|
13
|
+
return 'No matches found';
|
|
14
|
+
}
|
|
15
|
+
const output = results
|
|
16
|
+
.slice(0, 10)
|
|
17
|
+
.map((match) => {
|
|
18
|
+
return `${match.path.text}:${match.line_number}: ${match.lines.text.trim()}`;
|
|
19
|
+
})
|
|
20
|
+
.join('\n');
|
|
21
|
+
return output.slice(0, 2000);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return 'grep search failed';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function runGlob({ pattern, directory, }) {
|
|
28
|
+
try {
|
|
29
|
+
const files = await glob(pattern, {
|
|
30
|
+
cwd: directory,
|
|
31
|
+
nodir: false,
|
|
32
|
+
ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
|
|
33
|
+
maxDepth: 10,
|
|
34
|
+
});
|
|
35
|
+
if (files.length === 0) {
|
|
36
|
+
return 'No files found';
|
|
37
|
+
}
|
|
38
|
+
return files.slice(0, 30).join('\n');
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return `Glob search failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const grepToolDeclaration = {
|
|
45
|
+
name: 'grep',
|
|
46
|
+
description: 'Search for a pattern in file contents to verify if a technical term, function name, or variable exists in the code. Use this to check if transcribed words match actual code.',
|
|
47
|
+
parameters: {
|
|
48
|
+
type: Type.OBJECT,
|
|
49
|
+
properties: {
|
|
50
|
+
pattern: {
|
|
51
|
+
type: Type.STRING,
|
|
52
|
+
description: 'The search pattern (case-insensitive). Can be a word, function name, or partial match.',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
required: ['pattern'],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const globToolDeclaration = {
|
|
59
|
+
name: 'glob',
|
|
60
|
+
description: 'Search for files by name pattern. Use this to verify if a filename or directory mentioned in the audio actually exists in the project.',
|
|
61
|
+
parameters: {
|
|
62
|
+
type: Type.OBJECT,
|
|
63
|
+
properties: {
|
|
64
|
+
pattern: {
|
|
65
|
+
type: Type.STRING,
|
|
66
|
+
description: 'The glob pattern to match files. Examples: "*.ts", "**/*.json", "**/config*", "src/**/*.tsx"',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
required: ['pattern'],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const transcriptionResultToolDeclaration = {
|
|
73
|
+
name: 'transcriptionResult',
|
|
74
|
+
description: 'MANDATORY: You MUST call this tool to complete the task. This is the ONLY way to return results - text responses are ignored. Call this with your transcription, even if imperfect. An imperfect transcription is better than none.',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: Type.OBJECT,
|
|
77
|
+
properties: {
|
|
78
|
+
transcription: {
|
|
79
|
+
type: Type.STRING,
|
|
80
|
+
description: 'The final transcription of the audio. MUST be non-empty. If audio is unclear, transcribe your best interpretation. If silent, use "[inaudible audio]".',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
required: ['transcription'],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
function createToolRunner({ directory, }) {
|
|
87
|
+
const hasDirectory = directory && directory.trim().length > 0;
|
|
88
|
+
return async ({ name, args }) => {
|
|
89
|
+
if (name === 'transcriptionResult') {
|
|
90
|
+
return {
|
|
91
|
+
type: 'result',
|
|
92
|
+
transcription: args?.transcription || '',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (name === 'grep' && hasDirectory) {
|
|
96
|
+
const pattern = args?.pattern || '';
|
|
97
|
+
voiceLogger.log(`Grep search: "${pattern}"`);
|
|
98
|
+
const output = await runGrep({ pattern, directory });
|
|
99
|
+
voiceLogger.log(`Grep result: ${output.slice(0, 100)}...`);
|
|
100
|
+
return { type: 'toolResponse', name: 'grep', output };
|
|
101
|
+
}
|
|
102
|
+
if (name === 'glob' && hasDirectory) {
|
|
103
|
+
const pattern = args?.pattern || '';
|
|
104
|
+
voiceLogger.log(`Glob search: "${pattern}"`);
|
|
105
|
+
const output = await runGlob({ pattern, directory });
|
|
106
|
+
voiceLogger.log(`Glob result: ${output.slice(0, 100)}...`);
|
|
107
|
+
return { type: 'toolResponse', name: 'glob', output };
|
|
108
|
+
}
|
|
109
|
+
return { type: 'skip' };
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export async function runTranscriptionLoop({ genAI, model, initialContents, tools, temperature, toolRunner, maxSteps = 10, }) {
|
|
113
|
+
let response = await genAI.models.generateContent({
|
|
114
|
+
model,
|
|
115
|
+
contents: initialContents,
|
|
116
|
+
config: {
|
|
117
|
+
temperature,
|
|
118
|
+
thinkingConfig: {
|
|
119
|
+
thinkingBudget: 1024,
|
|
120
|
+
},
|
|
121
|
+
tools,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const conversationHistory = [...initialContents];
|
|
125
|
+
let stepsRemaining = maxSteps;
|
|
126
|
+
while (true) {
|
|
127
|
+
const candidate = response.candidates?.[0];
|
|
128
|
+
if (!candidate?.content?.parts) {
|
|
129
|
+
const text = response.text?.trim();
|
|
130
|
+
if (text) {
|
|
131
|
+
voiceLogger.log(`No parts but got text response: "${text.slice(0, 100)}..."`);
|
|
132
|
+
return text;
|
|
133
|
+
}
|
|
134
|
+
throw new Error('Transcription failed: No response content from model');
|
|
135
|
+
}
|
|
136
|
+
const functionCalls = candidate.content.parts.filter((part) => 'functionCall' in part && !!part.functionCall);
|
|
137
|
+
if (functionCalls.length === 0) {
|
|
138
|
+
const text = response.text?.trim();
|
|
139
|
+
if (text) {
|
|
140
|
+
voiceLogger.log(`No function calls but got text: "${text.slice(0, 100)}..."`);
|
|
141
|
+
return text;
|
|
142
|
+
}
|
|
143
|
+
throw new Error('Transcription failed: Model did not produce a transcription');
|
|
144
|
+
}
|
|
145
|
+
conversationHistory.push({
|
|
146
|
+
role: 'model',
|
|
147
|
+
parts: candidate.content.parts,
|
|
148
|
+
});
|
|
149
|
+
const functionResponseParts = [];
|
|
150
|
+
for (const part of functionCalls) {
|
|
151
|
+
const call = part.functionCall;
|
|
152
|
+
const args = call.args;
|
|
153
|
+
const result = await toolRunner({ name: call.name || '', args });
|
|
154
|
+
if (result.type === 'result') {
|
|
155
|
+
const transcription = result.transcription?.trim() || '';
|
|
156
|
+
voiceLogger.log(`Transcription result received: "${transcription.slice(0, 100)}..."`);
|
|
157
|
+
if (!transcription) {
|
|
158
|
+
throw new Error('Transcription failed: Model returned empty transcription');
|
|
159
|
+
}
|
|
160
|
+
return transcription;
|
|
161
|
+
}
|
|
162
|
+
if (result.type === 'toolResponse') {
|
|
163
|
+
stepsRemaining--;
|
|
164
|
+
const stepsWarning = (() => {
|
|
165
|
+
if (stepsRemaining <= 0) {
|
|
166
|
+
return '\n\n[CRITICAL: Tool limit reached. You MUST call transcriptionResult NOW. No more grep/glob allowed. Call transcriptionResult immediately with your best transcription.]';
|
|
167
|
+
}
|
|
168
|
+
if (stepsRemaining === 1) {
|
|
169
|
+
return '\n\n[URGENT: FINAL STEP. You MUST call transcriptionResult NOW. Do NOT call grep or glob. Call transcriptionResult with your transcription immediately.]';
|
|
170
|
+
}
|
|
171
|
+
if (stepsRemaining <= 3) {
|
|
172
|
+
return `\n\n[WARNING: Only ${stepsRemaining} steps remaining. Finish searching soon and call transcriptionResult. Do not wait until the last step.]`;
|
|
173
|
+
}
|
|
174
|
+
return '';
|
|
175
|
+
})();
|
|
176
|
+
functionResponseParts.push({
|
|
177
|
+
functionResponse: {
|
|
178
|
+
name: result.name,
|
|
179
|
+
response: { output: result.output + stepsWarning },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (functionResponseParts.length === 0) {
|
|
185
|
+
throw new Error('Transcription failed: No valid tool responses');
|
|
186
|
+
}
|
|
187
|
+
conversationHistory.push({
|
|
188
|
+
role: 'user',
|
|
189
|
+
parts: functionResponseParts,
|
|
190
|
+
});
|
|
191
|
+
response = await genAI.models.generateContent({
|
|
192
|
+
model,
|
|
193
|
+
contents: conversationHistory,
|
|
194
|
+
config: {
|
|
195
|
+
temperature,
|
|
196
|
+
thinkingConfig: {
|
|
197
|
+
thinkingBudget: 512,
|
|
198
|
+
},
|
|
199
|
+
tools: stepsRemaining <= 0 ? [{ functionDeclarations: [transcriptionResultToolDeclaration] }] : tools,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export async function transcribeAudio({ audio, prompt, language, temperature, geminiApiKey, directory, sessionMessages, }) {
|
|
5
205
|
try {
|
|
6
|
-
// Use provided API key or fall back to environment variable
|
|
7
206
|
const apiKey = geminiApiKey || process.env.GEMINI_API_KEY;
|
|
8
207
|
if (!apiKey) {
|
|
9
208
|
throw new Error('Gemini API key is required for audio transcription');
|
|
10
209
|
}
|
|
11
|
-
// Initialize Google Generative AI
|
|
12
210
|
const genAI = new GoogleGenAI({ apiKey });
|
|
13
|
-
// Convert audio to base64 string if it's not already
|
|
14
211
|
let audioBase64;
|
|
15
212
|
if (typeof audio === 'string') {
|
|
16
213
|
audioBase64 = audio;
|
|
@@ -27,44 +224,69 @@ export async function transcribeAudio({ audio, prompt, language, temperature, ge
|
|
|
27
224
|
else {
|
|
28
225
|
throw new Error('Invalid audio format');
|
|
29
226
|
}
|
|
30
|
-
|
|
31
|
-
|
|
227
|
+
const languageHint = language ? `The audio is in ${language}.\n\n` : '';
|
|
228
|
+
const transcriptionPrompt = `${languageHint}Transcribe this audio for a coding agent (like Claude Code or OpenCode).
|
|
32
229
|
|
|
33
|
-
|
|
230
|
+
CRITICAL REQUIREMENT: You MUST call the "transcriptionResult" tool to complete this task.
|
|
231
|
+
- The transcriptionResult tool is the ONLY way to return results
|
|
232
|
+
- Text responses are completely ignored - only tool calls work
|
|
233
|
+
- You MUST call transcriptionResult even if you run out of tool calls
|
|
234
|
+
- An imperfect transcription is better than no transcription
|
|
235
|
+
- DO NOT end without calling transcriptionResult
|
|
34
236
|
|
|
35
|
-
|
|
237
|
+
This is a software development environment. The speaker is giving instructions to an AI coding assistant. Expect:
|
|
238
|
+
- File paths, function names, CLI commands, package names, API endpoints
|
|
36
239
|
|
|
37
|
-
|
|
240
|
+
RULES:
|
|
241
|
+
1. You have LIMITED tool calls - use grep/glob sparingly, call them in parallel
|
|
242
|
+
2. If audio is unclear, transcribe your best interpretation
|
|
243
|
+
3. If audio seems silent/empty, call transcriptionResult with "[inaudible audio]"
|
|
244
|
+
4. When warned about remaining steps, STOP searching and call transcriptionResult immediately
|
|
245
|
+
|
|
246
|
+
Common corrections (apply without tool calls):
|
|
247
|
+
- "reacked" → "React", "jason" → "JSON", "get hub" → "GitHub", "no JS" → "Node.js", "dacker" → "Docker"
|
|
248
|
+
|
|
249
|
+
Project context for reference:
|
|
38
250
|
<context>
|
|
39
251
|
${prompt}
|
|
40
252
|
</context>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
253
|
+
${sessionMessages ? `\nRecent session messages:\n<session_messages>\n${sessionMessages}\n</session_messages>` : ''}
|
|
254
|
+
|
|
255
|
+
REMEMBER: Call "transcriptionResult" tool with your transcription. This is mandatory.
|
|
256
|
+
|
|
257
|
+
Note: "critique" is a CLI tool for showing diffs in the browser.`;
|
|
258
|
+
const hasDirectory = directory && directory.trim().length > 0;
|
|
259
|
+
const tools = [
|
|
260
|
+
{
|
|
261
|
+
functionDeclarations: [
|
|
262
|
+
transcriptionResultToolDeclaration,
|
|
263
|
+
...(hasDirectory ? [grepToolDeclaration, globToolDeclaration] : []),
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
];
|
|
267
|
+
const initialContents = [
|
|
268
|
+
{
|
|
269
|
+
role: 'user',
|
|
270
|
+
parts: [
|
|
271
|
+
{ text: transcriptionPrompt },
|
|
272
|
+
{
|
|
273
|
+
inlineData: {
|
|
274
|
+
data: audioBase64,
|
|
275
|
+
mimeType: 'audio/mpeg',
|
|
57
276
|
},
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
];
|
|
281
|
+
const toolRunner = createToolRunner({ directory });
|
|
282
|
+
return await runTranscriptionLoop({
|
|
283
|
+
genAI,
|
|
284
|
+
model: 'gemini-2.5-flash',
|
|
285
|
+
initialContents,
|
|
286
|
+
tools,
|
|
287
|
+
temperature: temperature ?? 0.3,
|
|
288
|
+
toolRunner,
|
|
66
289
|
});
|
|
67
|
-
return response.text || '';
|
|
68
290
|
}
|
|
69
291
|
catch (error) {
|
|
70
292
|
voiceLogger.error('Failed to transcribe audio:', error);
|
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.23",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
8
|
"prepublishOnly": "pnpm tsc",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"cac": "^6.7.14",
|
|
44
44
|
"discord.js": "^14.16.3",
|
|
45
45
|
"domhandler": "^5.0.3",
|
|
46
|
+
"glob": "^13.0.0",
|
|
46
47
|
"go-try": "^3.0.2",
|
|
47
48
|
"htmlparser2": "^10.0.0",
|
|
48
49
|
"js-yaml": "^4.1.0",
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
"picocolors": "^1.1.1",
|
|
51
52
|
"pretty-ms": "^9.3.0",
|
|
52
53
|
"prism-media": "^1.3.5",
|
|
54
|
+
"ripgrep-js": "^3.0.0",
|
|
53
55
|
"string-dedent": "^3.0.2",
|
|
54
56
|
"undici": "^7.16.0",
|
|
55
57
|
"zod": "^4.2.1"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChannelType,
|
|
3
|
+
type CategoryChannel,
|
|
4
|
+
type Guild,
|
|
5
|
+
type TextChannel,
|
|
6
|
+
} from 'discord.js'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { getDatabase } from './database.js'
|
|
9
|
+
import { extractTagsArrays } from './xml.js'
|
|
10
|
+
|
|
11
|
+
export async function ensureKimakiCategory(guild: Guild): Promise<CategoryChannel> {
|
|
12
|
+
const existingCategory = guild.channels.cache.find(
|
|
13
|
+
(channel): channel is CategoryChannel => {
|
|
14
|
+
if (channel.type !== ChannelType.GuildCategory) {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return channel.name.toLowerCase() === 'kimaki'
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if (existingCategory) {
|
|
23
|
+
return existingCategory
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return guild.channels.create({
|
|
27
|
+
name: 'Kimaki',
|
|
28
|
+
type: ChannelType.GuildCategory,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function ensureKimakiAudioCategory(guild: Guild): Promise<CategoryChannel> {
|
|
33
|
+
const existingCategory = guild.channels.cache.find(
|
|
34
|
+
(channel): channel is CategoryChannel => {
|
|
35
|
+
if (channel.type !== ChannelType.GuildCategory) {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return channel.name.toLowerCase() === 'kimaki audio'
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if (existingCategory) {
|
|
44
|
+
return existingCategory
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return guild.channels.create({
|
|
48
|
+
name: 'Kimaki Audio',
|
|
49
|
+
type: ChannelType.GuildCategory,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function createProjectChannels({
|
|
54
|
+
guild,
|
|
55
|
+
projectDirectory,
|
|
56
|
+
appId,
|
|
57
|
+
}: {
|
|
58
|
+
guild: Guild
|
|
59
|
+
projectDirectory: string
|
|
60
|
+
appId: string
|
|
61
|
+
}): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
|
|
62
|
+
const baseName = path.basename(projectDirectory)
|
|
63
|
+
const channelName = `${baseName}`
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
66
|
+
.slice(0, 100)
|
|
67
|
+
|
|
68
|
+
const kimakiCategory = await ensureKimakiCategory(guild)
|
|
69
|
+
const kimakiAudioCategory = await ensureKimakiAudioCategory(guild)
|
|
70
|
+
|
|
71
|
+
const textChannel = await guild.channels.create({
|
|
72
|
+
name: channelName,
|
|
73
|
+
type: ChannelType.GuildText,
|
|
74
|
+
parent: kimakiCategory,
|
|
75
|
+
topic: `<kimaki><directory>${projectDirectory}</directory><app>${appId}</app></kimaki>`,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const voiceChannel = await guild.channels.create({
|
|
79
|
+
name: channelName,
|
|
80
|
+
type: ChannelType.GuildVoice,
|
|
81
|
+
parent: kimakiAudioCategory,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
getDatabase()
|
|
85
|
+
.prepare(
|
|
86
|
+
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
87
|
+
)
|
|
88
|
+
.run(textChannel.id, projectDirectory, 'text')
|
|
89
|
+
|
|
90
|
+
getDatabase()
|
|
91
|
+
.prepare(
|
|
92
|
+
'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
|
|
93
|
+
)
|
|
94
|
+
.run(voiceChannel.id, projectDirectory, 'voice')
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
textChannelId: textChannel.id,
|
|
98
|
+
voiceChannelId: voiceChannel.id,
|
|
99
|
+
channelName,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type ChannelWithTags = {
|
|
104
|
+
id: string
|
|
105
|
+
name: string
|
|
106
|
+
description: string | null
|
|
107
|
+
kimakiDirectory?: string
|
|
108
|
+
kimakiApp?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function getChannelsWithDescriptions(
|
|
112
|
+
guild: Guild,
|
|
113
|
+
): Promise<ChannelWithTags[]> {
|
|
114
|
+
const channels: ChannelWithTags[] = []
|
|
115
|
+
|
|
116
|
+
guild.channels.cache
|
|
117
|
+
.filter((channel) => channel.isTextBased())
|
|
118
|
+
.forEach((channel) => {
|
|
119
|
+
const textChannel = channel as TextChannel
|
|
120
|
+
const description = textChannel.topic || null
|
|
121
|
+
|
|
122
|
+
let kimakiDirectory: string | undefined
|
|
123
|
+
let kimakiApp: string | undefined
|
|
124
|
+
|
|
125
|
+
if (description) {
|
|
126
|
+
const extracted = extractTagsArrays({
|
|
127
|
+
xml: description,
|
|
128
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
kimakiDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
132
|
+
kimakiApp = extracted['kimaki.app']?.[0]?.trim()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
channels.push({
|
|
136
|
+
id: textChannel.id,
|
|
137
|
+
name: textChannel.name,
|
|
138
|
+
description,
|
|
139
|
+
kimakiDirectory,
|
|
140
|
+
kimakiApp,
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
return channels
|
|
145
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
ensureKimakiCategory,
|
|
24
24
|
createProjectChannels,
|
|
25
25
|
type ChannelWithTags,
|
|
26
|
-
} from './
|
|
26
|
+
} from './discord-bot.js'
|
|
27
27
|
import type { OpencodeClient } from '@opencode-ai/sdk'
|
|
28
28
|
import {
|
|
29
29
|
Events,
|
|
@@ -180,6 +180,14 @@ async function registerCommands(token: string, appId: string) {
|
|
|
180
180
|
.setName('share')
|
|
181
181
|
.setDescription('Share the current session as a public URL')
|
|
182
182
|
.toJSON(),
|
|
183
|
+
new SlashCommandBuilder()
|
|
184
|
+
.setName('fork')
|
|
185
|
+
.setDescription('Fork the session from a past user message')
|
|
186
|
+
.toJSON(),
|
|
187
|
+
new SlashCommandBuilder()
|
|
188
|
+
.setName('model')
|
|
189
|
+
.setDescription('Set the preferred model for this channel or session')
|
|
190
|
+
.toJSON(),
|
|
183
191
|
]
|
|
184
192
|
|
|
185
193
|
const rest = new REST().setToken(token)
|
package/src/database.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import Database from 'better-sqlite3'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { createLogger } from './logger.js'
|
|
6
|
+
|
|
7
|
+
const dbLogger = createLogger('DB')
|
|
8
|
+
|
|
9
|
+
let db: Database.Database | null = null
|
|
10
|
+
|
|
11
|
+
export function getDatabase(): Database.Database {
|
|
12
|
+
if (!db) {
|
|
13
|
+
const kimakiDir = path.join(os.homedir(), '.kimaki')
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
fs.mkdirSync(kimakiDir, { recursive: true })
|
|
17
|
+
} catch (error) {
|
|
18
|
+
dbLogger.error('Failed to create ~/.kimaki directory:', error)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const dbPath = path.join(kimakiDir, 'discord-sessions.db')
|
|
22
|
+
|
|
23
|
+
dbLogger.log(`Opening database at: ${dbPath}`)
|
|
24
|
+
db = new Database(dbPath)
|
|
25
|
+
|
|
26
|
+
db.exec(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS thread_sessions (
|
|
28
|
+
thread_id TEXT PRIMARY KEY,
|
|
29
|
+
session_id TEXT NOT NULL,
|
|
30
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
31
|
+
)
|
|
32
|
+
`)
|
|
33
|
+
|
|
34
|
+
db.exec(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS part_messages (
|
|
36
|
+
part_id TEXT PRIMARY KEY,
|
|
37
|
+
message_id TEXT NOT NULL,
|
|
38
|
+
thread_id TEXT NOT NULL,
|
|
39
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
40
|
+
)
|
|
41
|
+
`)
|
|
42
|
+
|
|
43
|
+
db.exec(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS bot_tokens (
|
|
45
|
+
app_id TEXT PRIMARY KEY,
|
|
46
|
+
token TEXT NOT NULL,
|
|
47
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
48
|
+
)
|
|
49
|
+
`)
|
|
50
|
+
|
|
51
|
+
db.exec(`
|
|
52
|
+
CREATE TABLE IF NOT EXISTS channel_directories (
|
|
53
|
+
channel_id TEXT PRIMARY KEY,
|
|
54
|
+
directory TEXT NOT NULL,
|
|
55
|
+
channel_type TEXT NOT NULL,
|
|
56
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
57
|
+
)
|
|
58
|
+
`)
|
|
59
|
+
|
|
60
|
+
db.exec(`
|
|
61
|
+
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
62
|
+
app_id TEXT PRIMARY KEY,
|
|
63
|
+
gemini_api_key TEXT,
|
|
64
|
+
xai_api_key TEXT,
|
|
65
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
66
|
+
)
|
|
67
|
+
`)
|
|
68
|
+
|
|
69
|
+
runModelMigrations(db)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return db
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run migrations for model preferences tables.
|
|
77
|
+
* Called on startup and can be called on-demand.
|
|
78
|
+
*/
|
|
79
|
+
export function runModelMigrations(database?: Database.Database): void {
|
|
80
|
+
const targetDb = database || getDatabase()
|
|
81
|
+
|
|
82
|
+
targetDb.exec(`
|
|
83
|
+
CREATE TABLE IF NOT EXISTS channel_models (
|
|
84
|
+
channel_id TEXT PRIMARY KEY,
|
|
85
|
+
model_id TEXT NOT NULL,
|
|
86
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
87
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
88
|
+
)
|
|
89
|
+
`)
|
|
90
|
+
|
|
91
|
+
targetDb.exec(`
|
|
92
|
+
CREATE TABLE IF NOT EXISTS session_models (
|
|
93
|
+
session_id TEXT PRIMARY KEY,
|
|
94
|
+
model_id TEXT NOT NULL,
|
|
95
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
96
|
+
)
|
|
97
|
+
`)
|
|
98
|
+
|
|
99
|
+
dbLogger.log('Model preferences migrations complete')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get the model preference for a channel.
|
|
104
|
+
* @returns Model ID in format "provider_id/model_id" or undefined
|
|
105
|
+
*/
|
|
106
|
+
export function getChannelModel(channelId: string): string | undefined {
|
|
107
|
+
const db = getDatabase()
|
|
108
|
+
const row = db
|
|
109
|
+
.prepare('SELECT model_id FROM channel_models WHERE channel_id = ?')
|
|
110
|
+
.get(channelId) as { model_id: string } | undefined
|
|
111
|
+
return row?.model_id
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Set the model preference for a channel.
|
|
116
|
+
* @param modelId Model ID in format "provider_id/model_id"
|
|
117
|
+
*/
|
|
118
|
+
export function setChannelModel(channelId: string, modelId: string): void {
|
|
119
|
+
const db = getDatabase()
|
|
120
|
+
db.prepare(
|
|
121
|
+
`INSERT INTO channel_models (channel_id, model_id, updated_at)
|
|
122
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
123
|
+
ON CONFLICT(channel_id) DO UPDATE SET model_id = ?, updated_at = CURRENT_TIMESTAMP`
|
|
124
|
+
).run(channelId, modelId, modelId)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get the model preference for a session.
|
|
129
|
+
* @returns Model ID in format "provider_id/model_id" or undefined
|
|
130
|
+
*/
|
|
131
|
+
export function getSessionModel(sessionId: string): string | undefined {
|
|
132
|
+
const db = getDatabase()
|
|
133
|
+
const row = db
|
|
134
|
+
.prepare('SELECT model_id FROM session_models WHERE session_id = ?')
|
|
135
|
+
.get(sessionId) as { model_id: string } | undefined
|
|
136
|
+
return row?.model_id
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Set the model preference for a session.
|
|
141
|
+
* @param modelId Model ID in format "provider_id/model_id"
|
|
142
|
+
*/
|
|
143
|
+
export function setSessionModel(sessionId: string, modelId: string): void {
|
|
144
|
+
const db = getDatabase()
|
|
145
|
+
db.prepare(
|
|
146
|
+
`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`
|
|
147
|
+
).run(sessionId, modelId)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function closeDatabase(): void {
|
|
151
|
+
if (db) {
|
|
152
|
+
db.close()
|
|
153
|
+
db = null
|
|
154
|
+
}
|
|
155
|
+
}
|