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.
@@ -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,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:', error instanceof Error ? error.message : String(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
- lines.push(`**Session cost:** ${formattedCost}`);
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
  }
@@ -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
- // Store in appropriate table based on context
381
- if (context.isThread && context.sessionId) {
382
- // Store for session
383
- await setSessionModel(context.sessionId, fullModelId);
384
- modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`);
385
- // Check if there's a running request and abort+retry with new model
386
- let retried = false;
387
- if (context.thread) {
388
- retried = await abortAndRetrySession({
389
- sessionId: context.sessionId,
390
- thread: context.thread,
391
- projectDirectory: context.dir,
392
- appId: context.appId,
393
- });
394
- }
395
- if (retried) {
396
- await interaction.editReply({
397
- content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\`${fullModelId}\`\n_Retrying current request with new model..._`,
398
- components: [],
399
- });
400
- }
401
- else {
402
- await interaction.editReply({
403
- content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\`${fullModelId}\``,
404
- components: [],
405
- });
406
- }
407
- // Clean up the context from memory
408
- pendingModelContexts.delete(contextHash);
409
- }
410
- else {
411
- // Channel context - show scope selection menu
412
- context.selectedModelId = fullModelId;
413
- pendingModelContexts.set(contextHash, context);
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 === 'global') {
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.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);
@@ -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/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
- // Always run migrations - schema.sql uses IF NOT EXISTS so it's idempotent
42
- dbLogger.log(exists ? 'Existing database, running migrations...' : 'New database, running schema setup...');
43
- await migrateSchema(prisma);
44
- 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
+ }
45
55
  prismaInstance = prisma;
46
56
  return prisma;
47
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;
@@ -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, 1500);
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}${nextMessage.command.arguments ? ` ${nextMessage.command.arguments.slice(0, 120)}` : ''}`
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
- await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
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}${nextMessage.command.arguments ? ` ${nextMessage.command.arguments.slice(0, 120)}` : ''}`
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';