kimaki 0.4.62 → 0.4.64

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.
Files changed (44) hide show
  1. package/dist/ai-tool-to-genai.js +72 -53
  2. package/dist/ai-tool-to-genai.test.js +1 -1
  3. package/dist/ai-tool.js +6 -0
  4. package/dist/cli.js +47 -9
  5. package/dist/commands/permissions.js +1 -4
  6. package/dist/commands/queue.js +1 -1
  7. package/dist/commands/restart-opencode-server.js +50 -2
  8. package/dist/commands/user-command.js +8 -4
  9. package/dist/config.js +9 -0
  10. package/dist/db.js +15 -9
  11. package/dist/discord-bot.js +2 -2
  12. package/dist/genai-worker.js +4 -3
  13. package/dist/logger.js +10 -0
  14. package/dist/session-handler.js +11 -12
  15. package/dist/system-message.js +46 -33
  16. package/dist/tools.js +1 -1
  17. package/dist/unnest-code-blocks.js +32 -20
  18. package/dist/unnest-code-blocks.test.js +184 -1
  19. package/dist/voice-handler.js +10 -1
  20. package/package.json +6 -5
  21. package/schema.prisma +138 -0
  22. package/src/ai-tool-to-genai.test.ts +1 -1
  23. package/src/ai-tool-to-genai.ts +93 -68
  24. package/src/ai-tool.ts +37 -0
  25. package/src/cli.ts +49 -9
  26. package/src/commands/permissions.ts +1 -4
  27. package/src/commands/queue.ts +1 -1
  28. package/src/commands/restart-opencode-server.ts +55 -2
  29. package/src/commands/user-command.ts +12 -4
  30. package/src/config.ts +12 -0
  31. package/src/db.ts +15 -12
  32. package/src/discord-bot.ts +2 -2
  33. package/src/genai-worker-wrapper.ts +2 -3
  34. package/src/genai-worker.ts +4 -3
  35. package/src/genai.ts +2 -2
  36. package/src/logger.ts +12 -0
  37. package/src/openai-realtime.ts +0 -1
  38. package/src/session-handler.ts +12 -15
  39. package/src/system-message.ts +48 -33
  40. package/src/tools.ts +1 -1
  41. package/src/unnest-code-blocks.test.ts +196 -1
  42. package/src/unnest-code-blocks.ts +44 -18
  43. package/src/voice-handler.ts +11 -1
  44. package/src/worker-types.ts +2 -4
@@ -1,5 +1,5 @@
1
- // AI SDK to Google GenAI tool converter.
2
- // Transforms Vercel AI SDK tool definitions into Google GenAI CallableTool format
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
- // Map JSON Schema type to GenAI Type
14
- if (jsonSchema.type) {
15
- switch (jsonSchema.type) {
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 || 'float';
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 || 'int32';
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
- if (jsonSchema.items) {
33
- schema.items = jsonSchemaToGenAISchema(jsonSchema.items);
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 !== undefined) {
36
- schema.minItems = jsonSchema.minItems;
64
+ if (typeof jsonSchema.minItems === 'number') {
65
+ schema.minItems = String(jsonSchema.minItems);
37
66
  }
38
- if (jsonSchema.maxItems !== undefined) {
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
- for (const [key, value] of Object.entries(jsonSchema.properties)) {
47
- schema.properties[key] = jsonSchemaToGenAISchema(value);
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
- // Copy over common properties
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 (jsonSchema.default !== undefined) {
92
+ if ('default' in jsonSchema) {
69
93
  schema.default = jsonSchema.default;
70
94
  }
71
- if (jsonSchema.example !== undefined) {
72
- schema.example = jsonSchema.example;
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
- // Handle anyOf/oneOf as anyOf in GenAI
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
- // Handle number/string specific properties
85
- if (jsonSchema.minimum !== undefined) {
104
+ if (typeof jsonSchema.minimum === 'number') {
86
105
  schema.minimum = jsonSchema.minimum;
87
106
  }
88
- if (jsonSchema.maximum !== undefined) {
107
+ if (typeof jsonSchema.maximum === 'number') {
89
108
  schema.maximum = jsonSchema.maximum;
90
109
  }
91
- if (jsonSchema.minLength !== undefined) {
92
- schema.minLength = jsonSchema.minLength;
110
+ if (typeof jsonSchema.minLength === 'number') {
111
+ schema.minLength = String(jsonSchema.minLength);
93
112
  }
94
- if (jsonSchema.maxLength !== undefined) {
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 result = await tool.execute(functionCall.args || {}, {
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
- parts.push({
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
- parts.push({
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';
@@ -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,11 +15,11 @@ 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';
22
- import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity, setDefaultMentionMode, getProjectsDir } from './config.js';
22
+ import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity, setDefaultMentionMode, setCritiqueEnabled, getProjectsDir } from './config.js';
23
23
  import { sanitizeAgentName } from './commands/agent.js';
24
24
  import { showFileUploadButton, } from './commands/file-upload.js';
25
25
  import { execAsync } from './worktree-utils.js';
@@ -1105,6 +1105,7 @@ cli
1105
1105
  .option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
1106
1106
  .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text, text-and-essential-tools, or text-only)')
1107
1107
  .option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
1108
+ .option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
1108
1109
  .option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
1109
1110
  .action(async (options) => {
1110
1111
  try {
@@ -1126,6 +1127,10 @@ cli
1126
1127
  setDefaultMentionMode(true);
1127
1128
  cliLogger.log('Default mention mode: enabled (bot only responds when @mentioned)');
1128
1129
  }
1130
+ if (options.noCritique) {
1131
+ setCritiqueEnabled(false);
1132
+ cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
1133
+ }
1129
1134
  if (options.installUrl) {
1130
1135
  await initDatabase();
1131
1136
  const existingBot = await getBotToken();
@@ -1147,7 +1152,7 @@ cli
1147
1152
  });
1148
1153
  }
1149
1154
  catch (error) {
1150
- cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error));
1155
+ cliLogger.error('Unhandled error:', formatErrorWithStack(error));
1151
1156
  process.exit(EXIT_NO_RESTART);
1152
1157
  }
1153
1158
  });
@@ -1992,15 +1997,48 @@ cli
1992
1997
  cliLogger.error('Failed to connect to OpenCode:', getClient.message);
1993
1998
  process.exit(EXIT_NO_RESTART);
1994
1999
  }
2000
+ // Try current project first (fast path)
1995
2001
  const markdown = new ShareMarkdown(getClient());
1996
2002
  const result = await markdown.generate({ sessionID: sessionId });
1997
- if (result instanceof Error) {
1998
- cliLogger.error(result.message);
1999
- process.exit(EXIT_NO_RESTART);
2003
+ if (!(result instanceof Error)) {
2004
+ process.stdout.write(result);
2005
+ process.exit(0);
2000
2006
  }
2001
- // Print to stdout so it can be piped: kimaki session read <id> > ./tmp/session.md
2002
- process.stdout.write(result);
2003
- process.exit(0);
2007
+ // Session not found in current project, search across all projects.
2008
+ // project.list() returns all known projects globally from any OpenCode server,
2009
+ // but session.list/get are scoped to the server's own project. So we try each.
2010
+ cliLogger.log('Session not in current project, searching all projects...');
2011
+ const projectsResponse = await getClient().project.list({});
2012
+ const projects = projectsResponse.data || [];
2013
+ const otherProjects = projects
2014
+ .filter((p) => path.resolve(p.worktree) !== projectDirectory)
2015
+ .filter((p) => {
2016
+ try {
2017
+ fs.accessSync(p.worktree, fs.constants.R_OK);
2018
+ return true;
2019
+ }
2020
+ catch {
2021
+ return false;
2022
+ }
2023
+ })
2024
+ // Sort by most recently created first to find sessions faster
2025
+ .sort((a, b) => b.time.created - a.time.created);
2026
+ for (const project of otherProjects) {
2027
+ const dir = project.worktree;
2028
+ cliLogger.log(`Trying project: ${dir}`);
2029
+ const otherClient = await initializeOpencodeForDirectory(dir);
2030
+ if (otherClient instanceof Error) {
2031
+ continue;
2032
+ }
2033
+ const otherMarkdown = new ShareMarkdown(otherClient());
2034
+ const otherResult = await otherMarkdown.generate({ sessionID: sessionId });
2035
+ if (!(otherResult instanceof Error)) {
2036
+ process.stdout.write(otherResult);
2037
+ process.exit(0);
2038
+ }
2039
+ }
2040
+ cliLogger.error(`Session ${sessionId} not found in any project`);
2041
+ process.exit(EXIT_NO_RESTART);
2004
2042
  }
2005
2043
  catch (error) {
2006
2044
  cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
@@ -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.reply({
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();
@@ -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}${args ? ` ${args.slice(0, 100)}` : ''}`;
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);
@@ -1,9 +1,13 @@
1
1
  // /restart-opencode-server command - Restart the opencode server for the current channel.
2
2
  // Used for resolving opencode state issues, internal bugs, refreshing auth state, plugins, etc.
3
+ // Aborts all in-progress sessions in this channel before restarting to avoid orphaned requests.
3
4
  import { ChannelType } from 'discord.js';
4
- import { restartOpencodeServer } from '../opencode.js';
5
+ import { initializeOpencodeForDirectory, restartOpencodeServer } from '../opencode.js';
5
6
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
7
  import { createLogger, LogPrefix } from '../logger.js';
8
+ import { getAllThreadSessionIds, getThreadIdBySessionId } from '../database.js';
9
+ import { abortControllers } from '../session-handler.js';
10
+ import * as errore from 'errore';
7
11
  const logger = createLogger(LogPrefix.OPENCODE);
8
12
  export async function handleRestartOpencodeServerCommand({ command, appId }) {
9
13
  const channel = command.channel;
@@ -49,6 +53,49 @@ export async function handleRestartOpencodeServerCommand({ command, appId }) {
49
53
  }
50
54
  // Defer reply since restart may take a moment
51
55
  await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
56
+ // Abort all in-progress sessions in this channel before restarting.
57
+ // Find sessions with active abort controllers, check if their thread belongs
58
+ // to this channel (thread parentId matches, or command was run in the thread itself).
59
+ const parentChannelId = isThread ? channel.parentId : channel.id;
60
+ const activeSessionIds = [...abortControllers.keys()];
61
+ let abortedCount = 0;
62
+ if (activeSessionIds.length > 0) {
63
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
64
+ const client = !(getClient instanceof Error) ? getClient : null;
65
+ for (const sessionId of activeSessionIds) {
66
+ const threadId = await getThreadIdBySessionId(sessionId);
67
+ if (!threadId) {
68
+ continue;
69
+ }
70
+ // Check if thread belongs to this channel: either the thread IS this channel,
71
+ // or the thread's parent matches the parent channel
72
+ const threadChannel = await errore.tryAsync(() => {
73
+ return command.client.channels.fetch(threadId);
74
+ });
75
+ if (threadChannel instanceof Error || !threadChannel) {
76
+ continue;
77
+ }
78
+ const threadParentId = 'parentId' in threadChannel ? threadChannel.parentId : null;
79
+ if (threadId !== channel.id && threadParentId !== parentChannelId) {
80
+ continue;
81
+ }
82
+ const controller = abortControllers.get(sessionId);
83
+ if (controller) {
84
+ logger.log(`[RESTART] Aborting session ${sessionId} in thread ${threadId}`);
85
+ controller.abort(new Error('Server restart requested'));
86
+ abortControllers.delete(sessionId);
87
+ abortedCount++;
88
+ }
89
+ if (client) {
90
+ await errore.tryAsync(() => {
91
+ return client().session.abort({ path: { id: sessionId } });
92
+ });
93
+ }
94
+ }
95
+ }
96
+ if (abortedCount > 0) {
97
+ logger.log(`[RESTART] Aborted ${abortedCount} active session(s) before restart`);
98
+ }
52
99
  logger.log(`[RESTART] Restarting opencode server for directory: ${projectDirectory}`);
53
100
  const result = await restartOpencodeServer(projectDirectory);
54
101
  if (result instanceof Error) {
@@ -58,8 +105,9 @@ export async function handleRestartOpencodeServerCommand({ command, appId }) {
58
105
  });
59
106
  return;
60
107
  }
108
+ const abortMsg = abortedCount > 0 ? ` (aborted ${abortedCount} active session${abortedCount > 1 ? 's' : ''})` : '';
61
109
  await command.editReply({
62
- content: `🔄 Opencode server **restarted** successfully`,
110
+ content: `Opencode server **restarted** successfully${abortMsg}`,
63
111
  });
64
112
  logger.log(`[RESTART] Opencode server restarted for directory: ${projectDirectory}`);
65
113
  }
@@ -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}) with args: ${args}`);
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}**${args ? ` ${args.slice(0, 200)}${args.length > 200 ? '…' : ''}` : ''}`,
98
+ content: `**/${commandName}**`,
99
99
  flags: SILENT_MESSAGE_FLAGS,
100
100
  });
101
- const threadName = `/${commandName} ${args.slice(0, 80)}${args.length > 80 ? '…' : ''}`;
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/config.js CHANGED
@@ -51,6 +51,15 @@ export function getDefaultMentionMode() {
51
51
  export function setDefaultMentionMode(enabled) {
52
52
  defaultMentionMode = enabled;
53
53
  }
54
+ // Whether critique (diff upload to critique.work) is enabled in system prompts.
55
+ // Enabled by default, disabled via --no-critique CLI flag.
56
+ let critiqueEnabled = true;
57
+ export function getCritiqueEnabled() {
58
+ return critiqueEnabled;
59
+ }
60
+ export function setCritiqueEnabled(enabled) {
61
+ critiqueEnabled = enabled;
62
+ }
54
63
  export const registeredUserCommands = [];
55
64
  const DEFAULT_LOCK_PORT = 29988;
56
65
  /**
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,14 +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
- // WAL mode allows concurrent reads while writing instead of blocking.
42
- // busy_timeout makes SQLite retry for 5s instead of immediately failing with SQLITE_BUSY.
43
- await prisma.$executeRawUnsafe('PRAGMA journal_mode = WAL');
44
- await prisma.$executeRawUnsafe('PRAGMA busy_timeout = 5000');
45
- // Always run migrations - schema.sql uses IF NOT EXISTS so it's idempotent
46
- dbLogger.log(exists ? 'Existing database, running migrations...' : 'New database, running schema setup...');
47
- await migrateSchema(prisma);
48
- dbLogger.log('Schema migration complete');
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
+ }
49
55
  prismaInstance = prisma;
50
56
  return prisma;
51
57
  }
@@ -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 Rejection at:', promise, 'reason:', reason);
703
+ discordLogger.error('Unhandled rejection:', formatErrorWithStack(reason), 'at promise:', promise);
704
704
  });
705
705
  }
@@ -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
- workerLogger.error('Unhandled rejection in worker:', reason, 'at promise:', promise);
37
- sendError(`Worker unhandled rejection: ${reason}`);
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;
@@ -381,13 +381,19 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
381
381
  const threadPermissions = pendingPermissions.get(thread.id);
382
382
  if (threadPermissions && threadPermissions.size > 0) {
383
383
  const clientV2 = getOpencodeClientV2(directory);
384
- let rejectedCount = 0;
385
384
  for (const [permId, pendingPerm] of threadPermissions) {
386
385
  sessionLogger.log(`[PERMISSION] Auto-rejecting permission ${permId} due to new message`);
386
+ // Remove the permission buttons from the Discord message
387
+ const removeButtonsResult = await errore.tryAsync(async () => {
388
+ const msg = await thread.messages.fetch(pendingPerm.messageId);
389
+ await msg.edit({ components: [] });
390
+ });
391
+ if (removeButtonsResult instanceof Error) {
392
+ sessionLogger.log(`[PERMISSION] Failed to remove buttons for ${permId}:`, removeButtonsResult);
393
+ }
387
394
  if (!clientV2) {
388
395
  sessionLogger.log(`[PERMISSION] OpenCode v2 client unavailable for permission ${permId}`);
389
396
  cleanupPermissionContext(pendingPerm.contextHash);
390
- rejectedCount++;
391
397
  continue;
392
398
  }
393
399
  const rejectResult = await errore.tryAsync(() => {
@@ -400,16 +406,9 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
400
406
  if (rejectResult instanceof Error) {
401
407
  sessionLogger.log(`[PERMISSION] Failed to auto-reject permission ${permId}:`, rejectResult);
402
408
  }
403
- else {
404
- rejectedCount++;
405
- }
406
409
  cleanupPermissionContext(pendingPerm.contextHash);
407
410
  }
408
411
  pendingPermissions.delete(thread.id);
409
- if (rejectedCount > 0) {
410
- const plural = rejectedCount > 1 ? 's' : '';
411
- await sendThreadMessage(thread, `⚠️ ${rejectedCount} pending permission request${plural} auto-rejected due to new message`);
412
- }
413
412
  }
414
413
  // Answer any pending question tool with the user's message (silently, no thread message)
415
414
  const questionAnswered = await cancelPendingQuestion(thread.id, prompt);
@@ -1029,7 +1028,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
1029
1028
  }
1030
1029
  sessionLogger.log(`[QUEUE] Question shown but queue has messages, processing from ${nextMessage.username}`);
1031
1030
  const displayText = nextMessage.command
1032
- ? `/${nextMessage.command.name}${nextMessage.command.arguments ? ` ${nextMessage.command.arguments.slice(0, 120)}` : ''}`
1031
+ ? `/${nextMessage.command.name}`
1033
1032
  : `${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`;
1034
1033
  await sendThreadMessage(thread, `» **${nextMessage.username}:** ${displayText}`);
1035
1034
  setImmediate(() => {
@@ -1205,7 +1204,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
1205
1204
  sessionLogger.error('Failed to fetch provider info for context percentage:', contextResult);
1206
1205
  }
1207
1206
  const projectInfo = branchName ? `${folderName} ⋅ ${branchName} ⋅ ` : `${folderName} ⋅ `;
1208
- await sendThreadMessage(thread, `_${projectInfo}${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
1207
+ await sendThreadMessage(thread, `*${projectInfo}${sessionDuration}${contextInfo}${attachCommand}${modelInfo}${agentInfo}*`, { flags: NOTIFY_MESSAGE_FLAGS });
1209
1208
  sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
1210
1209
  // Process queued messages after completion
1211
1210
  const queue = messageQueue.get(thread.id);
@@ -1217,7 +1216,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
1217
1216
  sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`);
1218
1217
  // Show that queued message is being sent
1219
1218
  const displayText = nextMessage.command
1220
- ? `/${nextMessage.command.name}${nextMessage.command.arguments ? ` ${nextMessage.command.arguments.slice(0, 120)}` : ''}`
1219
+ ? `/${nextMessage.command.name}`
1221
1220
  : `${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`;
1222
1221
  await sendThreadMessage(thread, `» **${nextMessage.username}:** ${displayText}`);
1223
1222
  // Send the queued message as a new prompt (recursive call)