kimaki 0.4.61 → 0.4.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-tool-to-genai.js +72 -53
- package/dist/ai-tool-to-genai.test.js +1 -1
- package/dist/ai-tool.js +6 -0
- package/dist/cli.js +2 -2
- package/dist/commands/context-usage.js +3 -1
- package/dist/commands/model.js +62 -58
- package/dist/commands/permissions.js +1 -4
- package/dist/commands/queue.js +1 -1
- package/dist/commands/user-command.js +8 -4
- package/dist/db.js +15 -5
- package/dist/discord-bot.js +2 -2
- package/dist/genai-worker.js +4 -3
- package/dist/logger.js +10 -0
- package/dist/session-handler.js +11 -4
- package/dist/tools.js +1 -1
- package/dist/voice-handler.js +10 -1
- package/package.json +6 -5
- package/schema.prisma +138 -0
- package/src/ai-tool-to-genai.test.ts +1 -1
- package/src/ai-tool-to-genai.ts +93 -68
- package/src/ai-tool.ts +37 -0
- package/src/cli.ts +2 -2
- package/src/commands/context-usage.ts +3 -1
- package/src/commands/model.ts +64 -60
- package/src/commands/permissions.ts +1 -4
- package/src/commands/queue.ts +1 -1
- package/src/commands/user-command.ts +12 -4
- package/src/db.ts +15 -7
- package/src/discord-bot.ts +2 -2
- package/src/genai-worker-wrapper.ts +2 -3
- package/src/genai-worker.ts +4 -3
- package/src/genai.ts +2 -2
- package/src/logger.ts +12 -0
- package/src/openai-realtime.ts +0 -1
- package/src/session-handler.ts +12 -5
- package/src/tools.ts +1 -1
- package/src/voice-handler.ts +11 -1
- package/src/worker-types.ts +2 -4
package/dist/ai-tool-to-genai.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Transforms
|
|
1
|
+
// Tool definition to Google GenAI tool converter.
|
|
2
|
+
// Transforms Kimaki's minimal Tool definitions into Google GenAI CallableTool format
|
|
3
3
|
// for use with Gemini's function calling in the voice assistant.
|
|
4
4
|
import { Type } from '@google/genai';
|
|
5
5
|
import { z, toJSONSchema } from 'zod';
|
|
@@ -8,93 +8,112 @@ import { z, toJSONSchema } from 'zod';
|
|
|
8
8
|
* Based on the actual implementation used by the GenAI package:
|
|
9
9
|
* https://github.com/googleapis/js-genai/blob/027f09db662ce6b30f737b10b4d2efcb4282a9b6/src/_transformers.ts#L294
|
|
10
10
|
*/
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null;
|
|
13
|
+
}
|
|
11
14
|
function jsonSchemaToGenAISchema(jsonSchema) {
|
|
12
15
|
const schema = {};
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
if (typeof jsonSchema === 'boolean') {
|
|
17
|
+
return schema;
|
|
18
|
+
}
|
|
19
|
+
const jsonSchemaType = (() => {
|
|
20
|
+
if (!jsonSchema.type) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (typeof jsonSchema.type === 'string') {
|
|
24
|
+
return jsonSchema.type;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(jsonSchema.type)) {
|
|
27
|
+
return jsonSchema.type.find((t) => t !== 'null') || jsonSchema.type[0];
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
})();
|
|
31
|
+
if (Array.isArray(jsonSchema.type) && jsonSchema.type.includes('null')) {
|
|
32
|
+
schema.nullable = true;
|
|
33
|
+
}
|
|
34
|
+
if (jsonSchemaType) {
|
|
35
|
+
switch (jsonSchemaType) {
|
|
16
36
|
case 'string':
|
|
17
37
|
schema.type = Type.STRING;
|
|
18
38
|
break;
|
|
19
39
|
case 'number':
|
|
20
40
|
schema.type = Type.NUMBER;
|
|
21
|
-
schema.format = jsonSchema.format
|
|
41
|
+
schema.format = typeof jsonSchema.format === 'string' ? jsonSchema.format : 'float';
|
|
22
42
|
break;
|
|
23
43
|
case 'integer':
|
|
24
44
|
schema.type = Type.INTEGER;
|
|
25
|
-
schema.format = jsonSchema.format
|
|
45
|
+
schema.format = typeof jsonSchema.format === 'string' ? jsonSchema.format : 'int32';
|
|
26
46
|
break;
|
|
27
47
|
case 'boolean':
|
|
28
48
|
schema.type = Type.BOOLEAN;
|
|
29
49
|
break;
|
|
30
|
-
case 'array':
|
|
50
|
+
case 'array': {
|
|
31
51
|
schema.type = Type.ARRAY;
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
const itemsSchema = (() => {
|
|
53
|
+
if (!jsonSchema.items) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(jsonSchema.items)) {
|
|
57
|
+
return jsonSchema.items[0];
|
|
58
|
+
}
|
|
59
|
+
return jsonSchema.items;
|
|
60
|
+
})();
|
|
61
|
+
if (itemsSchema) {
|
|
62
|
+
schema.items = jsonSchemaToGenAISchema(itemsSchema);
|
|
34
63
|
}
|
|
35
|
-
if (jsonSchema.minItems
|
|
36
|
-
schema.minItems = jsonSchema.minItems;
|
|
64
|
+
if (typeof jsonSchema.minItems === 'number') {
|
|
65
|
+
schema.minItems = String(jsonSchema.minItems);
|
|
37
66
|
}
|
|
38
|
-
if (jsonSchema.maxItems
|
|
39
|
-
schema.maxItems = jsonSchema.maxItems;
|
|
67
|
+
if (typeof jsonSchema.maxItems === 'number') {
|
|
68
|
+
schema.maxItems = String(jsonSchema.maxItems);
|
|
40
69
|
}
|
|
41
70
|
break;
|
|
71
|
+
}
|
|
42
72
|
case 'object':
|
|
43
73
|
schema.type = Type.OBJECT;
|
|
44
74
|
if (jsonSchema.properties) {
|
|
45
|
-
schema.properties =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
75
|
+
schema.properties = Object.fromEntries(Object.entries(jsonSchema.properties).map(([key, value]) => [
|
|
76
|
+
key,
|
|
77
|
+
jsonSchemaToGenAISchema(value),
|
|
78
|
+
]));
|
|
49
79
|
}
|
|
50
|
-
if (jsonSchema.required) {
|
|
80
|
+
if (Array.isArray(jsonSchema.required)) {
|
|
51
81
|
schema.required = jsonSchema.required;
|
|
52
82
|
}
|
|
53
|
-
// Note: GenAI Schema doesn't have additionalProperties field
|
|
54
|
-
// We skip it for now
|
|
55
83
|
break;
|
|
56
|
-
default:
|
|
57
|
-
// For unknown types, keep as-is
|
|
58
|
-
schema.type = jsonSchema.type;
|
|
59
84
|
}
|
|
60
85
|
}
|
|
61
|
-
|
|
62
|
-
if (jsonSchema.description) {
|
|
86
|
+
if (typeof jsonSchema.description === 'string') {
|
|
63
87
|
schema.description = jsonSchema.description;
|
|
64
88
|
}
|
|
65
|
-
if (jsonSchema.enum) {
|
|
66
|
-
schema.enum = jsonSchema.enum.map(String);
|
|
89
|
+
if (Array.isArray(jsonSchema.enum)) {
|
|
90
|
+
schema.enum = jsonSchema.enum.map((x) => String(x));
|
|
67
91
|
}
|
|
68
|
-
if (
|
|
92
|
+
if ('default' in jsonSchema) {
|
|
69
93
|
schema.default = jsonSchema.default;
|
|
70
94
|
}
|
|
71
|
-
if (jsonSchema.
|
|
72
|
-
schema.example = jsonSchema.
|
|
73
|
-
}
|
|
74
|
-
if (jsonSchema.nullable) {
|
|
75
|
-
schema.nullable = true;
|
|
95
|
+
if (Array.isArray(jsonSchema.examples) && jsonSchema.examples.length > 0) {
|
|
96
|
+
schema.example = jsonSchema.examples[0];
|
|
76
97
|
}
|
|
77
|
-
|
|
78
|
-
if (jsonSchema.anyOf) {
|
|
98
|
+
if (Array.isArray(jsonSchema.anyOf)) {
|
|
79
99
|
schema.anyOf = jsonSchema.anyOf.map((s) => jsonSchemaToGenAISchema(s));
|
|
80
100
|
}
|
|
81
|
-
else if (jsonSchema.oneOf) {
|
|
101
|
+
else if (Array.isArray(jsonSchema.oneOf)) {
|
|
82
102
|
schema.anyOf = jsonSchema.oneOf.map((s) => jsonSchemaToGenAISchema(s));
|
|
83
103
|
}
|
|
84
|
-
|
|
85
|
-
if (jsonSchema.minimum !== undefined) {
|
|
104
|
+
if (typeof jsonSchema.minimum === 'number') {
|
|
86
105
|
schema.minimum = jsonSchema.minimum;
|
|
87
106
|
}
|
|
88
|
-
if (jsonSchema.maximum
|
|
107
|
+
if (typeof jsonSchema.maximum === 'number') {
|
|
89
108
|
schema.maximum = jsonSchema.maximum;
|
|
90
109
|
}
|
|
91
|
-
if (jsonSchema.minLength
|
|
92
|
-
schema.minLength = jsonSchema.minLength;
|
|
110
|
+
if (typeof jsonSchema.minLength === 'number') {
|
|
111
|
+
schema.minLength = String(jsonSchema.minLength);
|
|
93
112
|
}
|
|
94
|
-
if (jsonSchema.maxLength
|
|
95
|
-
schema.maxLength = jsonSchema.maxLength;
|
|
113
|
+
if (typeof jsonSchema.maxLength === 'number') {
|
|
114
|
+
schema.maxLength = String(jsonSchema.maxLength);
|
|
96
115
|
}
|
|
97
|
-
if (jsonSchema.pattern) {
|
|
116
|
+
if (typeof jsonSchema.pattern === 'string') {
|
|
98
117
|
schema.pattern = jsonSchema.pattern;
|
|
99
118
|
}
|
|
100
119
|
return schema;
|
|
@@ -156,12 +175,13 @@ export function aiToolToCallableTool(tool, name) {
|
|
|
156
175
|
// Execute the tool if it has an execute function
|
|
157
176
|
if (tool.execute) {
|
|
158
177
|
try {
|
|
159
|
-
const
|
|
178
|
+
const args = isRecord(functionCall.args) ? functionCall.args : {};
|
|
179
|
+
const result = await tool.execute(args, {
|
|
160
180
|
toolCallId: functionCall.id || '',
|
|
161
181
|
messages: [],
|
|
162
182
|
});
|
|
163
183
|
// Convert the result to a Part
|
|
164
|
-
|
|
184
|
+
const part = {
|
|
165
185
|
functionResponse: {
|
|
166
186
|
id: functionCall.id,
|
|
167
187
|
name: functionCall.name || toolName,
|
|
@@ -169,11 +189,12 @@ export function aiToolToCallableTool(tool, name) {
|
|
|
169
189
|
output: result,
|
|
170
190
|
},
|
|
171
191
|
},
|
|
172
|
-
}
|
|
192
|
+
};
|
|
193
|
+
parts.push(part);
|
|
173
194
|
}
|
|
174
195
|
catch (error) {
|
|
175
196
|
// Handle errors
|
|
176
|
-
|
|
197
|
+
const part = {
|
|
177
198
|
functionResponse: {
|
|
178
199
|
id: functionCall.id,
|
|
179
200
|
name: functionCall.name || toolName,
|
|
@@ -181,7 +202,8 @@ export function aiToolToCallableTool(tool, name) {
|
|
|
181
202
|
error: error instanceof Error ? error.message : String(error),
|
|
182
203
|
},
|
|
183
204
|
},
|
|
184
|
-
}
|
|
205
|
+
};
|
|
206
|
+
parts.push(part);
|
|
185
207
|
}
|
|
186
208
|
}
|
|
187
209
|
}
|
|
@@ -189,9 +211,6 @@ export function aiToolToCallableTool(tool, name) {
|
|
|
189
211
|
},
|
|
190
212
|
};
|
|
191
213
|
}
|
|
192
|
-
/**
|
|
193
|
-
* Helper to extract schema from AI SDK tool
|
|
194
|
-
*/
|
|
195
214
|
export function extractSchemaFromTool(tool) {
|
|
196
215
|
const inputSchema = tool.inputSchema;
|
|
197
216
|
if (!inputSchema) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { tool } from 'ai';
|
|
2
|
+
import { tool } from './ai-tool.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { Type } from '@google/genai';
|
|
5
5
|
import { aiToolToGenAIFunction, aiToolToCallableTool, extractSchemaFromTool, } from './ai-tool-to-genai.js';
|
package/dist/ai-tool.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Minimal tool definition helper used by Kimaki.
|
|
2
|
+
// This replaces the Vercel AI SDK `tool()` helper so Kimaki can define typed
|
|
3
|
+
// tools (Zod input schema + execute) without depending on the full `ai` package.
|
|
4
|
+
export function tool(definition) {
|
|
5
|
+
return definition;
|
|
6
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuild
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import * as errore from 'errore';
|
|
18
|
-
import { createLogger, LogPrefix } from './logger.js';
|
|
18
|
+
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
19
19
|
import { archiveThread, uploadFilesToDiscord, stripMentions } from './discord-utils.js';
|
|
20
20
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
21
21
|
import http from 'node:http';
|
|
@@ -1147,7 +1147,7 @@ cli
|
|
|
1147
1147
|
});
|
|
1148
1148
|
}
|
|
1149
1149
|
catch (error) {
|
|
1150
|
-
cliLogger.error('Unhandled error:',
|
|
1150
|
+
cliLogger.error('Unhandled error:', formatErrorWithStack(error));
|
|
1151
1151
|
process.exit(EXIT_NO_RESTART);
|
|
1152
1152
|
}
|
|
1153
1153
|
});
|
|
@@ -130,7 +130,9 @@ export async function handleContextUsageCommand({ command }) {
|
|
|
130
130
|
if (modelID) {
|
|
131
131
|
lines.push(`**Model:** ${modelID}`);
|
|
132
132
|
}
|
|
133
|
-
|
|
133
|
+
if (totalCost > 0) {
|
|
134
|
+
lines.push(`**Session cost:** ${formattedCost}`);
|
|
135
|
+
}
|
|
134
136
|
await command.editReply({ content: lines.join('\n') });
|
|
135
137
|
logger.log(`Context usage shown for session ${sessionId}: ${totalTokens} tokens`);
|
|
136
138
|
}
|
package/dist/commands/model.js
CHANGED
|
@@ -377,62 +377,40 @@ export async function handleModelSelectMenu(interaction) {
|
|
|
377
377
|
// Build full model ID: provider_id/model_id
|
|
378
378
|
const fullModelId = `${context.providerId}/${selectedModelId}`;
|
|
379
379
|
try {
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const scopeOptions = [
|
|
415
|
-
{
|
|
416
|
-
label: 'This channel only',
|
|
417
|
-
value: 'channel',
|
|
418
|
-
description: 'Override for this channel only',
|
|
419
|
-
},
|
|
420
|
-
{
|
|
421
|
-
label: 'Global default',
|
|
422
|
-
value: 'global',
|
|
423
|
-
description: 'Set for this channel and as default for all others',
|
|
424
|
-
},
|
|
425
|
-
];
|
|
426
|
-
const selectMenu = new StringSelectMenuBuilder()
|
|
427
|
-
.setCustomId(`model_scope:${contextHash}`)
|
|
428
|
-
.setPlaceholder('Apply to...')
|
|
429
|
-
.addOptions(scopeOptions);
|
|
430
|
-
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
431
|
-
await interaction.editReply({
|
|
432
|
-
content: `**Set Model Preference**\nModel: **${context.providerName}** / **${selectedModelId}**\n\`${fullModelId}\`\nApply to:`,
|
|
433
|
-
components: [actionRow],
|
|
434
|
-
});
|
|
435
|
-
}
|
|
380
|
+
// Always show scope selection menu
|
|
381
|
+
context.selectedModelId = fullModelId;
|
|
382
|
+
pendingModelContexts.set(contextHash, context);
|
|
383
|
+
const scopeOptions = [
|
|
384
|
+
// Show "this session" option when in a thread with an active session
|
|
385
|
+
...(context.isThread && context.sessionId
|
|
386
|
+
? [
|
|
387
|
+
{
|
|
388
|
+
label: 'This session only',
|
|
389
|
+
value: 'session',
|
|
390
|
+
description: 'Override for this session only',
|
|
391
|
+
},
|
|
392
|
+
]
|
|
393
|
+
: []),
|
|
394
|
+
{
|
|
395
|
+
label: 'This channel only',
|
|
396
|
+
value: 'channel',
|
|
397
|
+
description: 'Override for this channel only',
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
label: 'Global default',
|
|
401
|
+
value: 'global',
|
|
402
|
+
description: 'Set for this channel and as default for all others',
|
|
403
|
+
},
|
|
404
|
+
];
|
|
405
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
406
|
+
.setCustomId(`model_scope:${contextHash}`)
|
|
407
|
+
.setPlaceholder('Apply to...')
|
|
408
|
+
.addOptions(scopeOptions);
|
|
409
|
+
const actionRow = new ActionRowBuilder().addComponents(selectMenu);
|
|
410
|
+
await interaction.editReply({
|
|
411
|
+
content: `**Set Model Preference**\nModel: **${context.providerName}** / **${selectedModelId}**\n\`${fullModelId}\`\nApply to:`,
|
|
412
|
+
components: [actionRow],
|
|
413
|
+
});
|
|
436
414
|
}
|
|
437
415
|
catch (error) {
|
|
438
416
|
modelLogger.error('Error saving model preference:', error);
|
|
@@ -473,15 +451,41 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
473
451
|
const modelId = context.selectedModelId;
|
|
474
452
|
const modelDisplay = modelId.split('/')[1] || modelId;
|
|
475
453
|
try {
|
|
476
|
-
if (selectedScope === '
|
|
454
|
+
if (selectedScope === 'session') {
|
|
455
|
+
if (!context.sessionId) {
|
|
456
|
+
pendingModelContexts.delete(contextHash);
|
|
457
|
+
await interaction.editReply({
|
|
458
|
+
content: 'No active session in this thread. Please run /model in a thread with a session.',
|
|
459
|
+
components: [],
|
|
460
|
+
});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
await setSessionModel(context.sessionId, modelId);
|
|
464
|
+
modelLogger.log(`Set model ${modelId} for session ${context.sessionId}`);
|
|
465
|
+
let retried = false;
|
|
466
|
+
if (context.thread) {
|
|
467
|
+
retried = await abortAndRetrySession({
|
|
468
|
+
sessionId: context.sessionId,
|
|
469
|
+
thread: context.thread,
|
|
470
|
+
projectDirectory: context.dir,
|
|
471
|
+
appId: context.appId,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
const retryNote = retried ? '\n_Retrying current request with new model..._' : '';
|
|
475
|
+
await interaction.editReply({
|
|
476
|
+
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**\n\`${modelId}\`${retryNote}`,
|
|
477
|
+
components: [],
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
else if (selectedScope === 'global') {
|
|
477
481
|
if (!context.appId) {
|
|
482
|
+
pendingModelContexts.delete(contextHash);
|
|
478
483
|
await interaction.editReply({
|
|
479
484
|
content: 'Cannot set global model: channel is not linked to a bot',
|
|
480
485
|
components: [],
|
|
481
486
|
});
|
|
482
487
|
return;
|
|
483
488
|
}
|
|
484
|
-
// Set both global default and current channel
|
|
485
489
|
await setGlobalModel(context.appId, modelId);
|
|
486
490
|
await setChannelModel(context.channelId, modelId);
|
|
487
491
|
modelLogger.log(`Set global model ${modelId} for app ${context.appId} and channel ${context.channelId}`);
|
|
@@ -93,10 +93,7 @@ export async function handlePermissionButton(interaction) {
|
|
|
93
93
|
const response = actionPart.replace('permission_', '');
|
|
94
94
|
const context = pendingPermissionContexts.get(contextHash);
|
|
95
95
|
if (!context) {
|
|
96
|
-
await interaction.
|
|
97
|
-
content: 'This permission request has expired or was already handled.',
|
|
98
|
-
ephemeral: true,
|
|
99
|
-
});
|
|
96
|
+
await interaction.update({ components: [] });
|
|
100
97
|
return;
|
|
101
98
|
}
|
|
102
99
|
await interaction.deferUpdate();
|
package/dist/commands/queue.js
CHANGED
|
@@ -178,7 +178,7 @@ export async function handleQueueCommandCommand({ command, appId }) {
|
|
|
178
178
|
return;
|
|
179
179
|
}
|
|
180
180
|
const commandPayload = { name: commandName, arguments: args };
|
|
181
|
-
const displayText = `/${commandName}
|
|
181
|
+
const displayText = `/${commandName}`;
|
|
182
182
|
// Check if there's an active request running
|
|
183
183
|
const existingController = abortControllers.get(sessionId);
|
|
184
184
|
const hasActiveRequest = Boolean(existingController && !existingController.signal.aborted);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Handles slash commands that map to user-configured commands in opencode.json.
|
|
3
3
|
import { ChannelType } from 'discord.js';
|
|
4
4
|
import { handleOpencodeSession } from '../session-handler.js';
|
|
5
|
-
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
5
|
+
import { sendThreadMessage, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
6
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
7
|
import { getChannelDirectory, getThreadSession } from '../database.js';
|
|
8
8
|
import fs from 'node:fs';
|
|
@@ -12,7 +12,7 @@ export const handleUserCommand = async ({ command, appId }) => {
|
|
|
12
12
|
// Strip the -cmd suffix to get the actual OpenCode command name
|
|
13
13
|
const commandName = discordCommandName.replace(/-cmd$/, '');
|
|
14
14
|
const args = command.options.getString('arguments') || '';
|
|
15
|
-
userCommandLogger.log(`Executing /${commandName} (from /${discordCommandName})
|
|
15
|
+
userCommandLogger.log(`Executing /${commandName} (from /${discordCommandName}) argsLength=${args.length}`);
|
|
16
16
|
const channel = command.channel;
|
|
17
17
|
userCommandLogger.log(`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`);
|
|
18
18
|
const isThread = channel &&
|
|
@@ -95,10 +95,10 @@ export const handleUserCommand = async ({ command, appId }) => {
|
|
|
95
95
|
else if (textChannel) {
|
|
96
96
|
// Running in text channel - create a new thread
|
|
97
97
|
const starterMessage = await textChannel.send({
|
|
98
|
-
content: `**/${commandName}
|
|
98
|
+
content: `**/${commandName}**`,
|
|
99
99
|
flags: SILENT_MESSAGE_FLAGS,
|
|
100
100
|
});
|
|
101
|
-
const threadName = `/${commandName}
|
|
101
|
+
const threadName = `/${commandName}`;
|
|
102
102
|
const newThread = await starterMessage.startThread({
|
|
103
103
|
name: threadName.slice(0, 100),
|
|
104
104
|
autoArchiveDuration: 1440,
|
|
@@ -106,6 +106,10 @@ export const handleUserCommand = async ({ command, appId }) => {
|
|
|
106
106
|
});
|
|
107
107
|
// Add user to thread so it appears in their sidebar
|
|
108
108
|
await newThread.members.add(command.user.id);
|
|
109
|
+
if (args) {
|
|
110
|
+
const argsPreview = args.length > 1800 ? `${args.slice(0, 1800)}\n... truncated` : args;
|
|
111
|
+
await sendThreadMessage(newThread, `Args: ${argsPreview}`);
|
|
112
|
+
}
|
|
109
113
|
await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
|
|
110
114
|
await handleOpencodeSession({
|
|
111
115
|
prompt: '', // Not used when command is set
|
package/dist/db.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { PrismaLibSql } from '@prisma/adapter-libsql';
|
|
4
4
|
import { PrismaClient, Prisma } from './generated/client.js';
|
|
5
5
|
import { getDataDir } from './config.js';
|
|
6
|
-
import { createLogger, LogPrefix } from './logger.js';
|
|
6
|
+
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
9
|
const __dirname = path.dirname(__filename);
|
|
@@ -38,10 +38,20 @@ async function initializePrisma() {
|
|
|
38
38
|
dbLogger.log(`Opening database at: ${dbPath}`);
|
|
39
39
|
const adapter = new PrismaLibSql({ url: `file:${dbPath}` });
|
|
40
40
|
const prisma = new PrismaClient({ adapter });
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
try {
|
|
42
|
+
// WAL mode allows concurrent reads while writing instead of blocking.
|
|
43
|
+
// busy_timeout makes SQLite retry for 5s instead of immediately failing with SQLITE_BUSY.
|
|
44
|
+
await prisma.$executeRawUnsafe('PRAGMA journal_mode = WAL');
|
|
45
|
+
await prisma.$executeRawUnsafe('PRAGMA busy_timeout = 5000');
|
|
46
|
+
// Always run migrations - schema.sql uses IF NOT EXISTS so it's idempotent
|
|
47
|
+
dbLogger.log(exists ? 'Existing database, running migrations...' : 'New database, running schema setup...');
|
|
48
|
+
await migrateSchema(prisma);
|
|
49
|
+
dbLogger.log('Schema migration complete');
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
dbLogger.error('Prisma init failed:', formatErrorWithStack(error));
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
45
55
|
prismaInstance = prisma;
|
|
46
56
|
return prisma;
|
|
47
57
|
}
|
package/dist/discord-bot.js
CHANGED
|
@@ -24,7 +24,7 @@ export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels,
|
|
|
24
24
|
import { ChannelType, Client, Events, GatewayIntentBits, Partials, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
25
25
|
import fs from 'node:fs';
|
|
26
26
|
import * as errore from 'errore';
|
|
27
|
-
import { createLogger, LogPrefix } from './logger.js';
|
|
27
|
+
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
28
28
|
import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js';
|
|
29
29
|
import { setGlobalDispatcher, Agent } from 'undici';
|
|
30
30
|
// Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
|
|
@@ -700,6 +700,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
700
700
|
discordLogger.log('Ignoring unhandled rejection during shutdown:', reason);
|
|
701
701
|
return;
|
|
702
702
|
}
|
|
703
|
-
discordLogger.error('Unhandled
|
|
703
|
+
discordLogger.error('Unhandled rejection:', formatErrorWithStack(reason), 'at promise:', promise);
|
|
704
704
|
});
|
|
705
705
|
}
|
package/dist/genai-worker.js
CHANGED
|
@@ -10,7 +10,7 @@ import * as prism from 'prism-media';
|
|
|
10
10
|
import { startGenAiSession } from './genai.js';
|
|
11
11
|
import { getTools } from './tools.js';
|
|
12
12
|
import { mkdir } from 'node:fs/promises';
|
|
13
|
-
import { createLogger, LogPrefix } from './logger.js';
|
|
13
|
+
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
14
14
|
if (!parentPort) {
|
|
15
15
|
throw new Error('This module must be run as a worker thread');
|
|
16
16
|
}
|
|
@@ -33,8 +33,9 @@ process.on('uncaughtException', (error) => {
|
|
|
33
33
|
process.exit(1);
|
|
34
34
|
});
|
|
35
35
|
process.on('unhandledRejection', (reason, promise) => {
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const formattedReason = formatErrorWithStack(reason);
|
|
37
|
+
workerLogger.error('Unhandled rejection in worker:', formattedReason, 'at promise:', promise);
|
|
38
|
+
sendError(`Worker unhandled rejection: ${formattedReason}`);
|
|
38
39
|
});
|
|
39
40
|
// Audio configuration
|
|
40
41
|
const AUDIO_CONFIG = {
|
package/dist/logger.js
CHANGED
|
@@ -66,6 +66,16 @@ function formatArg(arg) {
|
|
|
66
66
|
}
|
|
67
67
|
return util.inspect(arg, { colors: true, depth: 4 });
|
|
68
68
|
}
|
|
69
|
+
export function formatErrorWithStack(error) {
|
|
70
|
+
if (error instanceof Error) {
|
|
71
|
+
return error.stack ?? `${error.name}: ${error.message}`;
|
|
72
|
+
}
|
|
73
|
+
if (typeof error === 'string') {
|
|
74
|
+
return error;
|
|
75
|
+
}
|
|
76
|
+
// Keep this stable and safe for unknown values (handles circular structures).
|
|
77
|
+
return util.inspect(error, { colors: false, depth: 4 });
|
|
78
|
+
}
|
|
69
79
|
function writeToFile(level, prefix, args) {
|
|
70
80
|
if (!isDev) {
|
|
71
81
|
return;
|
package/dist/session-handler.js
CHANGED
|
@@ -17,6 +17,7 @@ import { isAbortError } from './utils.js';
|
|
|
17
17
|
import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts, } from './commands/ask-question.js';
|
|
18
18
|
import { showPermissionButtons, cleanupPermissionContext, addPermissionRequestToContext, arePatternsCoveredBy, } from './commands/permissions.js';
|
|
19
19
|
import { cancelPendingFileUpload } from './commands/file-upload.js';
|
|
20
|
+
import { execAsync } from './worktree-utils.js';
|
|
20
21
|
import * as errore from 'errore';
|
|
21
22
|
const sessionLogger = createLogger(LogPrefix.SESSION);
|
|
22
23
|
const voiceLogger = createLogger(LogPrefix.VOICE);
|
|
@@ -494,7 +495,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
494
495
|
await Promise.race([
|
|
495
496
|
previousHandler,
|
|
496
497
|
new Promise((resolve) => {
|
|
497
|
-
setTimeout(resolve,
|
|
498
|
+
setTimeout(resolve, 2500);
|
|
498
499
|
}),
|
|
499
500
|
]);
|
|
500
501
|
}
|
|
@@ -1028,7 +1029,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
1028
1029
|
}
|
|
1029
1030
|
sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
|
|
1030
1031
|
const displayText = nextMessage.command
|
|
1031
|
-
? `/${nextMessage.command.name}
|
|
1032
|
+
? `/${nextMessage.command.name}`
|
|
1032
1033
|
: `${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`;
|
|
1033
1034
|
await sendThreadMessage(thread, `» **${nextMessage.username}:** ${displayText}`);
|
|
1034
1035
|
setImmediate(() => {
|
|
@@ -1165,6 +1166,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
1165
1166
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
|
|
1166
1167
|
const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : '';
|
|
1167
1168
|
let contextInfo = '';
|
|
1169
|
+
const folderName = path.basename(sdkDirectory);
|
|
1170
|
+
const branchResult = await errore.tryAsync(() => {
|
|
1171
|
+
return execAsync('git symbolic-ref --short HEAD', { cwd: sdkDirectory });
|
|
1172
|
+
});
|
|
1173
|
+
const branchName = branchResult instanceof Error ? '' : branchResult.stdout.trim();
|
|
1168
1174
|
const contextResult = await errore.tryAsync(async () => {
|
|
1169
1175
|
// Fetch final token count from API since message.updated events can arrive
|
|
1170
1176
|
// after session.idle due to race conditions in event ordering
|
|
@@ -1198,7 +1204,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
1198
1204
|
if (contextResult instanceof Error) {
|
|
1199
1205
|
sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult);
|
|
1200
1206
|
}
|
|
1201
|
-
|
|
1207
|
+
const projectInfo = branchName ? `${folderName} ⋅ ${branchName} ⋅ ` : `${folderName} ⋅ `;
|
|
1208
|
+
await sendThreadMessage(thread, `*${projectInfo}${sessionDuration}${contextInfo}${attachCommand}${modelInfo}${agentInfo}*`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
1202
1209
|
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
|
|
1203
1210
|
// Process queued messages after completion
|
|
1204
1211
|
const queue = messageQueue.get(thread.id);
|
|
@@ -1210,7 +1217,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
1210
1217
|
sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`);
|
|
1211
1218
|
// Show that queued message is being sent
|
|
1212
1219
|
const displayText = nextMessage.command
|
|
1213
|
-
? `/${nextMessage.command.name}
|
|
1220
|
+
? `/${nextMessage.command.name}`
|
|
1214
1221
|
: `${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`;
|
|
1215
1222
|
await sendThreadMessage(thread, `» **${nextMessage.username}:** ${displayText}`);
|
|
1216
1223
|
// Send the queued message as a new prompt (recursive call)
|
package/dist/tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Voice assistant tool definitions for the GenAI worker.
|
|
2
2
|
// Provides tools for managing OpenCode sessions (create, submit, abort),
|
|
3
3
|
// listing chats, searching files, and reading session messages.
|
|
4
|
-
import { tool } from 'ai';
|
|
4
|
+
import { tool } from './ai-tool.js';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import net from 'node:net';
|