kimaki 0.4.49 → 0.4.51

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.
@@ -40,34 +40,38 @@ export async function ensureKimakiAudioCategory(guild, botName) {
40
40
  type: ChannelType.GuildCategory,
41
41
  });
42
42
  }
43
- export async function createProjectChannels({ guild, projectDirectory, appId, botName, }) {
43
+ export async function createProjectChannels({ guild, projectDirectory, appId, botName, enableVoiceChannels = false, }) {
44
44
  const baseName = path.basename(projectDirectory);
45
45
  const channelName = `${baseName}`
46
46
  .toLowerCase()
47
47
  .replace(/[^a-z0-9-]/g, '-')
48
48
  .slice(0, 100);
49
49
  const kimakiCategory = await ensureKimakiCategory(guild, botName);
50
- const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName);
51
50
  const textChannel = await guild.channels.create({
52
51
  name: channelName,
53
52
  type: ChannelType.GuildText,
54
53
  parent: kimakiCategory,
55
54
  // Channel configuration is stored in SQLite, not in the topic
56
55
  });
57
- const voiceChannel = await guild.channels.create({
58
- name: channelName,
59
- type: ChannelType.GuildVoice,
60
- parent: kimakiAudioCategory,
61
- });
62
56
  getDatabase()
63
57
  .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
64
58
  .run(textChannel.id, projectDirectory, 'text', appId);
65
- getDatabase()
66
- .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
67
- .run(voiceChannel.id, projectDirectory, 'voice', appId);
59
+ let voiceChannelId = null;
60
+ if (enableVoiceChannels) {
61
+ const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName);
62
+ const voiceChannel = await guild.channels.create({
63
+ name: channelName,
64
+ type: ChannelType.GuildVoice,
65
+ parent: kimakiAudioCategory,
66
+ });
67
+ getDatabase()
68
+ .prepare('INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)')
69
+ .run(voiceChannel.id, projectDirectory, 'voice', appId);
70
+ voiceChannelId = voiceChannel.id;
71
+ }
68
72
  return {
69
73
  textChannelId: textChannel.id,
70
- voiceChannelId: voiceChannel.id,
74
+ voiceChannelId,
71
75
  channelName,
72
76
  };
73
77
  }
package/dist/cli.js CHANGED
@@ -299,7 +299,7 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
299
299
  .setName('level')
300
300
  .setDescription('Verbosity level')
301
301
  .setRequired(true)
302
- .addChoices({ name: 'tools-and-text (default)', value: 'tools-and-text' }, { name: 'text-only', value: 'text-only' });
302
+ .addChoices({ name: 'tools-and-text (default)', value: 'tools-and-text' }, { name: 'text-and-essential-tools', value: 'text-and-essential-tools' }, { name: 'text-only', value: 'text-only' });
303
303
  return option;
304
304
  })
305
305
  .toJSON(),
@@ -428,7 +428,7 @@ async function backgroundInit({ currentDir, token, appId, }) {
428
428
  cliLogger.error('Background init failed:', error instanceof Error ? error.message : String(error));
429
429
  }
430
430
  }
431
- async function run({ restart, addChannels, useWorktrees }) {
431
+ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels }) {
432
432
  startCaffeinate();
433
433
  const forceSetup = Boolean(restart);
434
434
  intro('🤖 Discord Bot Setup');
@@ -777,6 +777,7 @@ async function run({ restart, addChannels, useWorktrees }) {
777
777
  projectDirectory: project.worktree,
778
778
  appId,
779
779
  botName: discordClient.user?.username,
780
+ enableVoiceChannels,
780
781
  });
781
782
  createdChannels.push({
782
783
  name: channelName,
@@ -823,7 +824,8 @@ cli
823
824
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
824
825
  .option('--install-url', 'Print the bot install URL and exit')
825
826
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
826
- .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text or text-only)')
827
+ .option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
828
+ .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text, text-and-essential-tools, or text-only)')
827
829
  .action(async (options) => {
828
830
  try {
829
831
  // Set data directory early, before any database access
@@ -832,8 +834,9 @@ cli
832
834
  cliLogger.log(`Using data directory: ${getDataDir()}`);
833
835
  }
834
836
  if (options.verbosity) {
835
- if (options.verbosity !== 'tools-and-text' && options.verbosity !== 'text-only') {
836
- cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use "tools-and-text" or "text-only".`);
837
+ const validLevels = ['tools-and-text', 'text-and-essential-tools', 'text-only'];
838
+ if (!validLevels.includes(options.verbosity)) {
839
+ cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use one of: ${validLevels.join(', ')}`);
837
840
  process.exit(EXIT_NO_RESTART);
838
841
  }
839
842
  setDefaultVerbosity(options.verbosity);
@@ -858,6 +861,7 @@ cli
858
861
  addChannels: options.addChannels,
859
862
  dataDir: options.dataDir,
860
863
  useWorktrees: options.useWorktrees,
864
+ enableVoiceChannels: options.enableVoiceChannels,
861
865
  });
862
866
  }
863
867
  catch (error) {
@@ -52,7 +52,8 @@ export async function handleAddProjectCommand({ command, appId }) {
52
52
  appId,
53
53
  botName: command.client.user?.username,
54
54
  });
55
- await command.editReply(`✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``);
55
+ const voiceInfo = voiceChannelId ? `\n🔊 Voice: <#${voiceChannelId}>` : '';
56
+ await command.editReply(`✅ Created channels for project:\n📝 Text: <#${textChannelId}>${voiceInfo}\n📁 Directory: \`${directory}\``);
56
57
  logger.log(`Created channels for project ${channelName} at ${directory}`);
57
58
  }
58
59
  catch (error) {
@@ -83,7 +83,8 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
83
83
  }
84
84
  const { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName } = result;
85
85
  const textChannel = (await guild.channels.fetch(textChannelId));
86
- await command.editReply(`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n_Starting session..._`);
86
+ const voiceInfo = voiceChannelId ? `\n🔊 Voice: <#${voiceChannelId}>` : '';
87
+ await command.editReply(`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>${voiceInfo}\n_Starting session..._`);
87
88
  const starterMessage = await textChannel.send({
88
89
  content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
89
90
  flags: SILENT_MESSAGE_FLAGS,
@@ -43,9 +43,15 @@ export async function handleVerbosityCommand({ command, appId, }) {
43
43
  }
44
44
  setChannelVerbosity(channelId, level);
45
45
  verbosityLogger.log(`[VERBOSITY] Set channel ${channelId} to ${level}`);
46
- const description = level === 'text-only'
47
- ? 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.'
48
- : 'All output will be shown, including tool executions and status messages.';
46
+ const description = (() => {
47
+ if (level === 'text-only') {
48
+ return 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.';
49
+ }
50
+ if (level === 'text-and-essential-tools') {
51
+ return 'Text responses and essential tools (edits, custom MCP tools) will be shown. Read, search, and navigation tools will be hidden.';
52
+ }
53
+ return 'All output will be shown, including tool executions and status messages.';
54
+ })();
49
55
  await command.reply({
50
56
  content: `Verbosity set to **${level}** for this channel.\n${description}\nThis is a per-channel setting and applies immediately, including any active sessions.`,
51
57
  ephemeral: true,
@@ -117,6 +117,17 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
117
117
  }
118
118
  }
119
119
  if (message.guild && message.member) {
120
+ // Check for "no-kimaki" role first - blocks user regardless of other permissions.
121
+ // This implements the "four-eyes principle": even owners must remove this role
122
+ // to use the bot, adding friction to prevent accidental usage.
123
+ const hasNoKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'no-kimaki');
124
+ if (hasNoKimakiRole) {
125
+ await message.reply({
126
+ content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
127
+ flags: SILENT_MESSAGE_FLAGS,
128
+ });
129
+ return;
130
+ }
120
131
  const isOwner = message.member.id === message.guild.ownerId;
121
132
  const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator);
122
133
  const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
@@ -0,0 +1,107 @@
1
+ // Image processing utilities for Discord attachments.
2
+ // Uses sharp (optional) to resize large images and heic-convert (optional) for HEIC support.
3
+ // Falls back gracefully if dependencies are not available.
4
+ import { createLogger, LogPrefix } from './logger.js';
5
+ const logger = createLogger(LogPrefix.FORMATTING);
6
+ const MAX_DIMENSION = 1500;
7
+ const HEIC_MIME_TYPES = ['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'];
8
+ let sharpModule = undefined;
9
+ let heicConvertModule = undefined;
10
+ async function tryLoadSharp() {
11
+ if (sharpModule !== undefined) {
12
+ return sharpModule;
13
+ }
14
+ try {
15
+ sharpModule = (await import('sharp')).default;
16
+ logger.log('sharp loaded successfully');
17
+ return sharpModule;
18
+ }
19
+ catch {
20
+ logger.log('sharp not available, images will be sent at original size');
21
+ sharpModule = null;
22
+ return null;
23
+ }
24
+ }
25
+ async function tryLoadHeicConvert() {
26
+ if (heicConvertModule !== undefined) {
27
+ return heicConvertModule;
28
+ }
29
+ try {
30
+ const mod = await import('heic-convert');
31
+ heicConvertModule = mod.default;
32
+ logger.log('heic-convert loaded successfully');
33
+ return heicConvertModule;
34
+ }
35
+ catch {
36
+ logger.log('heic-convert not available, HEIC images will be sent as-is');
37
+ heicConvertModule = null;
38
+ return null;
39
+ }
40
+ }
41
+ function isHeicMime(mime) {
42
+ return HEIC_MIME_TYPES.includes(mime.toLowerCase());
43
+ }
44
+ export async function processImage(buffer, mime) {
45
+ // Skip non-images (PDFs, etc.)
46
+ if (!mime.startsWith('image/')) {
47
+ return { buffer, mime };
48
+ }
49
+ let workingBuffer = buffer;
50
+ let workingMime = mime;
51
+ // Handle HEIC conversion first (before sharp, since sharp doesn't support HEIC)
52
+ if (isHeicMime(mime)) {
53
+ const heicConvert = await tryLoadHeicConvert();
54
+ if (heicConvert) {
55
+ try {
56
+ const outputArrayBuffer = await heicConvert({
57
+ buffer: workingBuffer.buffer.slice(workingBuffer.byteOffset, workingBuffer.byteOffset + workingBuffer.byteLength),
58
+ format: 'JPEG',
59
+ quality: 0.85,
60
+ });
61
+ workingBuffer = Buffer.from(outputArrayBuffer);
62
+ workingMime = 'image/jpeg';
63
+ logger.log(`Converted HEIC to JPEG (${buffer.length} → ${workingBuffer.length} bytes)`);
64
+ }
65
+ catch (error) {
66
+ logger.error('Failed to convert HEIC, sending original:', error);
67
+ return { buffer, mime };
68
+ }
69
+ }
70
+ else {
71
+ // No heic-convert available, return original (LLM might not support it)
72
+ logger.log('HEIC image detected but heic-convert not available, sending as-is');
73
+ return { buffer, mime };
74
+ }
75
+ }
76
+ // Now process with sharp (resize + ensure JPEG output)
77
+ const sharp = await tryLoadSharp();
78
+ if (!sharp) {
79
+ return { buffer: workingBuffer, mime: workingMime };
80
+ }
81
+ try {
82
+ const image = sharp(workingBuffer);
83
+ const metadata = await image.metadata();
84
+ const { width, height } = metadata;
85
+ const needsResize = width && height && (width > MAX_DIMENSION || height > MAX_DIMENSION);
86
+ if (!needsResize) {
87
+ // Still convert to JPEG for consistency (unless already JPEG from HEIC conversion)
88
+ const outputBuffer = await image.jpeg({ quality: 85 }).toBuffer();
89
+ logger.log(`Converted image to JPEG: ${width}x${height} (${outputBuffer.length} bytes)`);
90
+ return { buffer: outputBuffer, mime: 'image/jpeg' };
91
+ }
92
+ // Resize and convert to JPEG
93
+ const outputBuffer = await image
94
+ .resize(MAX_DIMENSION, MAX_DIMENSION, {
95
+ fit: 'inside',
96
+ withoutEnlargement: true,
97
+ })
98
+ .jpeg({ quality: 85 })
99
+ .toBuffer();
100
+ logger.log(`Resized image: ${width}x${height} → max ${MAX_DIMENSION}px (${outputBuffer.length} bytes)`);
101
+ return { buffer: outputBuffer, mime: 'image/jpeg' };
102
+ }
103
+ catch (error) {
104
+ logger.error('Failed to process image with sharp, using working buffer:', error);
105
+ return { buffer: workingBuffer, mime: workingMime };
106
+ }
107
+ }
@@ -1,12 +1,10 @@
1
1
  // OpenCode message part formatting for Discord.
2
2
  // Converts SDK message parts (text, tools, reasoning) to Discord-friendly format,
3
3
  // handles file attachments, and provides tool summary generation.
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
4
  import * as errore from 'errore';
7
5
  import { createLogger, LogPrefix } from './logger.js';
8
6
  import { FetchError } from './errors.js';
9
- const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments');
7
+ import { processImage } from './image-utils.js';
10
8
  const logger = createLogger(LogPrefix.FORMATTING);
11
9
  /**
12
10
  * Escapes Discord inline markdown characters so dynamic content
@@ -148,10 +146,6 @@ export async function getFileAttachments(message) {
148
146
  if (fileAttachments.length === 0) {
149
147
  return [];
150
148
  }
151
- // ensure tmp directory exists
152
- if (!fs.existsSync(ATTACHMENTS_DIR)) {
153
- fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
154
- }
155
149
  const results = await Promise.all(fileAttachments.map(async (attachment) => {
156
150
  const response = await errore.tryAsync({
157
151
  try: () => fetch(attachment.url),
@@ -165,15 +159,18 @@ export async function getFileAttachments(message) {
165
159
  logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`);
166
160
  return null;
167
161
  }
168
- const buffer = Buffer.from(await response.arrayBuffer());
169
- const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`);
170
- fs.writeFileSync(localPath, buffer);
171
- logger.log(`Downloaded attachment to ${localPath}`);
162
+ const rawBuffer = Buffer.from(await response.arrayBuffer());
163
+ const originalMime = attachment.contentType || 'application/octet-stream';
164
+ // Process image (resize if needed, convert to JPEG)
165
+ const { buffer, mime } = await processImage(rawBuffer, originalMime);
166
+ const base64 = buffer.toString('base64');
167
+ const dataUrl = `data:${mime};base64,${base64}`;
168
+ logger.log(`Attachment ${attachment.name}: ${rawBuffer.length} → ${buffer.length} bytes, ${mime}`);
172
169
  return {
173
170
  type: 'file',
174
- mime: attachment.contentType || 'application/octet-stream',
171
+ mime,
175
172
  filename: attachment.name,
176
- url: localPath,
173
+ url: dataUrl,
177
174
  };
178
175
  }));
179
176
  return results.filter((r) => r !== null);
@@ -16,6 +16,21 @@ const sessionLogger = createLogger(LogPrefix.SESSION);
16
16
  const voiceLogger = createLogger(LogPrefix.VOICE);
17
17
  const discordLogger = createLogger(LogPrefix.DISCORD);
18
18
  export const abortControllers = new Map();
19
+ // Built-in tools that are hidden in text-and-essential-tools verbosity mode.
20
+ // Essential tools (edits, bash, todos, tasks, custom MCP tools) are shown; these navigation/read tools are hidden.
21
+ const NON_ESSENTIAL_TOOLS = new Set([
22
+ 'read',
23
+ 'list',
24
+ 'glob',
25
+ 'grep',
26
+ 'todoread',
27
+ 'skill',
28
+ 'question',
29
+ 'webfetch',
30
+ ]);
31
+ function isEssentialTool(toolName) {
32
+ return !NON_ESSENTIAL_TOOLS.has(toolName);
33
+ }
19
34
  // Track multiple pending permissions per thread (keyed by permission ID)
20
35
  // OpenCode handles blocking/sequencing - we just need to track all pending permissions
21
36
  // to avoid duplicates and properly clean up on auto-reject
@@ -326,10 +341,23 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
326
341
  return getChannelVerbosity(verbosityChannelId);
327
342
  };
328
343
  const sendPartMessage = async (part) => {
344
+ const verbosity = getVerbosity();
329
345
  // In text-only mode, only send text parts (the ⬥ diamond messages)
330
- if (getVerbosity() === 'text-only' && part.type !== 'text') {
346
+ if (verbosity === 'text-only' && part.type !== 'text') {
331
347
  return;
332
348
  }
349
+ // In text-and-essential-tools mode, show text + essential tools (edits, custom MCP tools)
350
+ if (verbosity === 'text-and-essential-tools') {
351
+ if (part.type === 'text') {
352
+ // text is always shown
353
+ }
354
+ else if (part.type === 'tool' && isEssentialTool(part.tool)) {
355
+ // essential tools are shown
356
+ }
357
+ else {
358
+ return;
359
+ }
360
+ }
333
361
  const content = formatPart(part) + '\n\n';
334
362
  if (!content.trim() || content.length === 0) {
335
363
  // discordLogger.log(`SKIP: Part ${part.id} has no content`)
@@ -487,7 +515,7 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
487
515
  agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1;
488
516
  const label = `${agent}-${agentSpawnCounts[agent]}`;
489
517
  subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined });
490
- // Skip task messages in text-only mode
518
+ // Show task messages in tools-and-text and text-and-essential-tools modes
491
519
  if (getVerbosity() !== 'text-only') {
492
520
  const taskDisplay = `┣ task **${label}** _${description}_`;
493
521
  await sendThreadMessage(thread, taskDisplay + '\n\n');
@@ -497,24 +525,37 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
497
525
  }
498
526
  return;
499
527
  }
500
- if (part.type === 'tool' && part.state.status === 'completed' && getVerbosity() !== 'text-only') {
501
- const output = part.state.output || '';
502
- const outputTokens = Math.ceil(output.length / 4);
503
- const largeOutputThreshold = 3000;
504
- if (outputTokens >= largeOutputThreshold) {
505
- const formattedTokens = outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens);
506
- const percentageSuffix = (() => {
507
- if (!modelContextLimit) {
508
- return '';
509
- }
510
- const pct = (outputTokens / modelContextLimit) * 100;
511
- if (pct < 1) {
512
- return '';
513
- }
514
- return ` (${pct.toFixed(1)}%)`;
515
- })();
516
- const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
517
- await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
528
+ // Show large output notifications for tools that are visible in current verbosity mode
529
+ if (part.type === 'tool' && part.state.status === 'completed') {
530
+ const showLargeOutput = (() => {
531
+ const verbosity = getVerbosity();
532
+ if (verbosity === 'text-only') {
533
+ return false;
534
+ }
535
+ if (verbosity === 'text-and-essential-tools') {
536
+ return isEssentialTool(part.tool);
537
+ }
538
+ return true;
539
+ })();
540
+ if (showLargeOutput) {
541
+ const output = part.state.output || '';
542
+ const outputTokens = Math.ceil(output.length / 4);
543
+ const largeOutputThreshold = 3000;
544
+ if (outputTokens >= largeOutputThreshold) {
545
+ const formattedTokens = outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens);
546
+ const percentageSuffix = (() => {
547
+ if (!modelContextLimit) {
548
+ return '';
549
+ }
550
+ const pct = (outputTokens / modelContextLimit) * 100;
551
+ if (pct < 1) {
552
+ return '';
553
+ }
554
+ return ` (${pct.toFixed(1)}%)`;
555
+ })();
556
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`;
557
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
558
+ }
518
559
  }
519
560
  }
520
561
  if (part.type === 'reasoning') {
@@ -542,10 +583,17 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
542
583
  }
543
584
  };
544
585
  const handleSubtaskPart = async (part, subtaskInfo) => {
586
+ const verbosity = getVerbosity();
545
587
  // In text-only mode, skip all subtask output (they're tool-related)
546
- if (getVerbosity() === 'text-only') {
588
+ if (verbosity === 'text-only') {
547
589
  return;
548
590
  }
591
+ // In text-and-essential-tools mode, only show essential tools from subtasks
592
+ if (verbosity === 'text-and-essential-tools') {
593
+ if (part.type !== 'tool' || !isEssentialTool(part.tool)) {
594
+ return;
595
+ }
596
+ }
549
597
  if (part.type === 'step-start' || part.type === 'step-finish') {
550
598
  return;
551
599
  }
@@ -943,10 +991,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
943
991
  sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({
944
992
  mime: img.mime,
945
993
  filename: img.filename,
946
- url: img.url.slice(0, 100),
994
+ urlPreview: img.url.slice(0, 50) + '...',
947
995
  })));
948
- const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n');
949
- return `${prompt}\n\n**attached images:**\n${imagePathsList}`;
996
+ // Just list filenames, not the full base64 URLs (images are passed as separate parts)
997
+ const imageList = images.map((img) => `- ${img.filename}`).join('\n');
998
+ return `${prompt}\n\n**attached images:**\n${imageList}`;
950
999
  })();
951
1000
  const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
952
1001
  sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
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.49",
5
+ "version": "0.4.51",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -14,6 +14,7 @@
14
14
  "@opencode-ai/plugin": "^1.1.12",
15
15
  "@types/better-sqlite3": "^7.6.13",
16
16
  "@types/bun": "latest",
17
+ "@types/heic-convert": "^2.1.0",
17
18
  "@types/js-yaml": "^4.0.9",
18
19
  "@types/ms": "^2.1.0",
19
20
  "@types/node": "^24.3.0",
@@ -45,7 +46,9 @@
45
46
  },
46
47
  "optionalDependencies": {
47
48
  "@discordjs/opus": "^0.10.0",
48
- "prism-media": "^1.3.5"
49
+ "heic-convert": "^2.1.0",
50
+ "prism-media": "^1.3.5",
51
+ "sharp": "^0.34.5"
49
52
  },
50
53
  "scripts": {
51
54
  "dev": "tsx --env-file .env src/cli.ts",
@@ -63,12 +63,14 @@ export async function createProjectChannels({
63
63
  projectDirectory,
64
64
  appId,
65
65
  botName,
66
+ enableVoiceChannels = false,
66
67
  }: {
67
68
  guild: Guild
68
69
  projectDirectory: string
69
70
  appId: string
70
71
  botName?: string
71
- }): Promise<{ textChannelId: string; voiceChannelId: string; channelName: string }> {
72
+ enableVoiceChannels?: boolean
73
+ }): Promise<{ textChannelId: string; voiceChannelId: string | null; channelName: string }> {
72
74
  const baseName = path.basename(projectDirectory)
73
75
  const channelName = `${baseName}`
74
76
  .toLowerCase()
@@ -76,7 +78,6 @@ export async function createProjectChannels({
76
78
  .slice(0, 100)
77
79
 
78
80
  const kimakiCategory = await ensureKimakiCategory(guild, botName)
79
- const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
80
81
 
81
82
  const textChannel = await guild.channels.create({
82
83
  name: channelName,
@@ -85,27 +86,35 @@ export async function createProjectChannels({
85
86
  // Channel configuration is stored in SQLite, not in the topic
86
87
  })
87
88
 
88
- const voiceChannel = await guild.channels.create({
89
- name: channelName,
90
- type: ChannelType.GuildVoice,
91
- parent: kimakiAudioCategory,
92
- })
93
-
94
89
  getDatabase()
95
90
  .prepare(
96
91
  'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
97
92
  )
98
93
  .run(textChannel.id, projectDirectory, 'text', appId)
99
94
 
100
- getDatabase()
101
- .prepare(
102
- 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
103
- )
104
- .run(voiceChannel.id, projectDirectory, 'voice', appId)
95
+ let voiceChannelId: string | null = null
96
+
97
+ if (enableVoiceChannels) {
98
+ const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
99
+
100
+ const voiceChannel = await guild.channels.create({
101
+ name: channelName,
102
+ type: ChannelType.GuildVoice,
103
+ parent: kimakiAudioCategory,
104
+ })
105
+
106
+ getDatabase()
107
+ .prepare(
108
+ 'INSERT OR REPLACE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
109
+ )
110
+ .run(voiceChannel.id, projectDirectory, 'voice', appId)
111
+
112
+ voiceChannelId = voiceChannel.id
113
+ }
105
114
 
106
115
  return {
107
116
  textChannelId: textChannel.id,
108
- voiceChannelId: voiceChannel.id,
117
+ voiceChannelId,
109
118
  channelName,
110
119
  }
111
120
  }
package/src/cli.ts CHANGED
@@ -209,6 +209,7 @@ type CliOptions = {
209
209
  addChannels?: boolean
210
210
  dataDir?: string
211
211
  useWorktrees?: boolean
212
+ enableVoiceChannels?: boolean
212
213
  }
213
214
 
214
215
  // Commands to skip when registering user commands (reserved names)
@@ -390,6 +391,7 @@ async function registerCommands({
390
391
  .setRequired(true)
391
392
  .addChoices(
392
393
  { name: 'tools-and-text (default)', value: 'tools-and-text' },
394
+ { name: 'text-and-essential-tools', value: 'text-and-essential-tools' },
393
395
  { name: 'text-only', value: 'text-only' },
394
396
  )
395
397
  return option
@@ -594,7 +596,7 @@ async function backgroundInit({
594
596
  }
595
597
  }
596
598
 
597
- async function run({ restart, addChannels, useWorktrees }: CliOptions) {
599
+ async function run({ restart, addChannels, useWorktrees, enableVoiceChannels }: CliOptions) {
598
600
  startCaffeinate()
599
601
 
600
602
  const forceSetup = Boolean(restart)
@@ -1056,6 +1058,7 @@ async function run({ restart, addChannels, useWorktrees }: CliOptions) {
1056
1058
  projectDirectory: project.worktree,
1057
1059
  appId,
1058
1060
  botName: discordClient.user?.username,
1061
+ enableVoiceChannels,
1059
1062
  })
1060
1063
 
1061
1064
  createdChannels.push({
@@ -1122,7 +1125,8 @@ cli
1122
1125
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
1123
1126
  .option('--install-url', 'Print the bot install URL and exit')
1124
1127
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
1125
- .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text or text-only)')
1128
+ .option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
1129
+ .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text, text-and-essential-tools, or text-only)')
1126
1130
  .action(
1127
1131
  async (options: {
1128
1132
  restart?: boolean
@@ -1130,6 +1134,7 @@ cli
1130
1134
  dataDir?: string
1131
1135
  installUrl?: boolean
1132
1136
  useWorktrees?: boolean
1137
+ enableVoiceChannels?: boolean
1133
1138
  verbosity?: string
1134
1139
  }) => {
1135
1140
  try {
@@ -1140,11 +1145,12 @@ cli
1140
1145
  }
1141
1146
 
1142
1147
  if (options.verbosity) {
1143
- if (options.verbosity !== 'tools-and-text' && options.verbosity !== 'text-only') {
1144
- cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use "tools-and-text" or "text-only".`)
1148
+ const validLevels = ['tools-and-text', 'text-and-essential-tools', 'text-only']
1149
+ if (!validLevels.includes(options.verbosity)) {
1150
+ cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use one of: ${validLevels.join(', ')}`)
1145
1151
  process.exit(EXIT_NO_RESTART)
1146
1152
  }
1147
- setDefaultVerbosity(options.verbosity)
1153
+ setDefaultVerbosity(options.verbosity as 'tools-and-text' | 'text-and-essential-tools' | 'text-only')
1148
1154
  cliLogger.log(`Default verbosity: ${options.verbosity}`)
1149
1155
  }
1150
1156
 
@@ -1170,6 +1176,7 @@ cli
1170
1176
  addChannels: options.addChannels,
1171
1177
  dataDir: options.dataDir,
1172
1178
  useWorktrees: options.useWorktrees,
1179
+ enableVoiceChannels: options.enableVoiceChannels,
1173
1180
  })
1174
1181
  } catch (error) {
1175
1182
  cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error))
@@ -72,8 +72,9 @@ export async function handleAddProjectCommand({ command, appId }: CommandContext
72
72
  botName: command.client.user?.username,
73
73
  })
74
74
 
75
+ const voiceInfo = voiceChannelId ? `\n🔊 Voice: <#${voiceChannelId}>` : ''
75
76
  await command.editReply(
76
- `✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``,
77
+ `✅ Created channels for project:\n📝 Text: <#${textChannelId}>${voiceInfo}\n📁 Directory: \`${directory}\``,
77
78
  )
78
79
 
79
80
  logger.log(`Created channels for project ${channelName} at ${directory}`)
@@ -31,7 +31,7 @@ export async function createNewProject({
31
31
  botName?: string
32
32
  }): Promise<{
33
33
  textChannelId: string
34
- voiceChannelId: string
34
+ voiceChannelId: string | null
35
35
  channelName: string
36
36
  projectDirectory: string
37
37
  sanitizedName: string
@@ -124,8 +124,9 @@ export async function handleCreateNewProjectCommand({
124
124
  const { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName } = result
125
125
  const textChannel = (await guild.channels.fetch(textChannelId)) as TextChannel
126
126
 
127
+ const voiceInfo = voiceChannelId ? `\n🔊 Voice: <#${voiceChannelId}>` : ''
127
128
  await command.editReply(
128
- `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n_Starting session..._`,
129
+ `✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>${voiceInfo}\n_Starting session..._`,
129
130
  )
130
131
 
131
132
  const starterMessage = await textChannel.send({
@@ -60,9 +60,15 @@ export async function handleVerbosityCommand({
60
60
  setChannelVerbosity(channelId, level)
61
61
  verbosityLogger.log(`[VERBOSITY] Set channel ${channelId} to ${level}`)
62
62
 
63
- const description = level === 'text-only'
64
- ? 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.'
65
- : 'All output will be shown, including tool executions and status messages.'
63
+ const description = (() => {
64
+ if (level === 'text-only') {
65
+ return 'Only text responses will be shown. Tool executions, status messages, and thinking will be hidden.'
66
+ }
67
+ if (level === 'text-and-essential-tools') {
68
+ return 'Text responses and essential tools (edits, custom MCP tools) will be shown. Read, search, and navigation tools will be hidden.'
69
+ }
70
+ return 'All output will be shown, including tool executions and status messages.'
71
+ })()
66
72
 
67
73
  await command.reply({
68
74
  content: `Verbosity set to **${level}** for this channel.\n${description}\nThis is a per-channel setting and applies immediately, including any active sessions.`,
package/src/database.ts CHANGED
@@ -363,7 +363,10 @@ export function runWorktreeSettingsMigrations(database?: Database.Database): voi
363
363
  }
364
364
 
365
365
  // Verbosity levels for controlling output detail
366
- export type VerbosityLevel = 'tools-and-text' | 'text-only'
366
+ // - tools-and-text: shows all output including tool executions
367
+ // - text-and-essential-tools: shows text + edits + custom MCP tools, hides read/search/navigation tools
368
+ // - text-only: only shows text responses (⬥ diamond parts)
369
+ export type VerbosityLevel = 'tools-and-text' | 'text-and-essential-tools' | 'text-only'
367
370
 
368
371
  export function runVerbosityMigrations(database?: Database.Database): void {
369
372
  const targetDb = database || getDatabase()
@@ -193,6 +193,20 @@ export async function startDiscordBot({
193
193
  }
194
194
 
195
195
  if (message.guild && message.member) {
196
+ // Check for "no-kimaki" role first - blocks user regardless of other permissions.
197
+ // This implements the "four-eyes principle": even owners must remove this role
198
+ // to use the bot, adding friction to prevent accidental usage.
199
+ const hasNoKimakiRole = message.member.roles.cache.some(
200
+ (role) => role.name.toLowerCase() === 'no-kimaki',
201
+ )
202
+ if (hasNoKimakiRole) {
203
+ await message.reply({
204
+ content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
205
+ flags: SILENT_MESSAGE_FLAGS,
206
+ })
207
+ return
208
+ }
209
+
196
210
  const isOwner = message.member.id === message.guild.ownerId
197
211
  const isAdmin = message.member.permissions.has(PermissionsBitField.Flags.Administrator)
198
212
  const canManageServer = message.member.permissions.has(
@@ -0,0 +1,132 @@
1
+ // Image processing utilities for Discord attachments.
2
+ // Uses sharp (optional) to resize large images and heic-convert (optional) for HEIC support.
3
+ // Falls back gracefully if dependencies are not available.
4
+
5
+ import { createLogger, LogPrefix } from './logger.js'
6
+
7
+ const logger = createLogger(LogPrefix.FORMATTING)
8
+
9
+ const MAX_DIMENSION = 1500
10
+ const HEIC_MIME_TYPES = ['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence']
11
+
12
+ type SharpModule = typeof import('sharp')
13
+ type HeicConvertFn = (options: {
14
+ buffer: ArrayBufferLike
15
+ format: 'JPEG' | 'PNG'
16
+ quality?: number
17
+ }) => Promise<ArrayBuffer>
18
+
19
+ let sharpModule: SharpModule | null | undefined = undefined
20
+ let heicConvertModule: HeicConvertFn | null | undefined = undefined
21
+
22
+ async function tryLoadSharp(): Promise<SharpModule | null> {
23
+ if (sharpModule !== undefined) {
24
+ return sharpModule
25
+ }
26
+ try {
27
+ sharpModule = (await import('sharp')).default as unknown as SharpModule
28
+ logger.log('sharp loaded successfully')
29
+ return sharpModule
30
+ } catch {
31
+ logger.log('sharp not available, images will be sent at original size')
32
+ sharpModule = null
33
+ return null
34
+ }
35
+ }
36
+
37
+ async function tryLoadHeicConvert(): Promise<HeicConvertFn | null> {
38
+ if (heicConvertModule !== undefined) {
39
+ return heicConvertModule
40
+ }
41
+ try {
42
+ const mod = await import('heic-convert')
43
+ heicConvertModule = mod.default as HeicConvertFn
44
+ logger.log('heic-convert loaded successfully')
45
+ return heicConvertModule
46
+ } catch {
47
+ logger.log('heic-convert not available, HEIC images will be sent as-is')
48
+ heicConvertModule = null
49
+ return null
50
+ }
51
+ }
52
+
53
+ function isHeicMime(mime: string): boolean {
54
+ return HEIC_MIME_TYPES.includes(mime.toLowerCase())
55
+ }
56
+
57
+ export async function processImage(
58
+ buffer: Buffer,
59
+ mime: string,
60
+ ): Promise<{ buffer: Buffer; mime: string }> {
61
+ // Skip non-images (PDFs, etc.)
62
+ if (!mime.startsWith('image/')) {
63
+ return { buffer, mime }
64
+ }
65
+
66
+ let workingBuffer = buffer
67
+ let workingMime = mime
68
+
69
+ // Handle HEIC conversion first (before sharp, since sharp doesn't support HEIC)
70
+ if (isHeicMime(mime)) {
71
+ const heicConvert = await tryLoadHeicConvert()
72
+ if (heicConvert) {
73
+ try {
74
+ const outputArrayBuffer = await heicConvert({
75
+ buffer: workingBuffer.buffer.slice(
76
+ workingBuffer.byteOffset,
77
+ workingBuffer.byteOffset + workingBuffer.byteLength,
78
+ ),
79
+ format: 'JPEG',
80
+ quality: 0.85,
81
+ })
82
+ workingBuffer = Buffer.from(outputArrayBuffer)
83
+ workingMime = 'image/jpeg'
84
+ logger.log(`Converted HEIC to JPEG (${buffer.length} → ${workingBuffer.length} bytes)`)
85
+ } catch (error) {
86
+ logger.error('Failed to convert HEIC, sending original:', error)
87
+ return { buffer, mime }
88
+ }
89
+ } else {
90
+ // No heic-convert available, return original (LLM might not support it)
91
+ logger.log('HEIC image detected but heic-convert not available, sending as-is')
92
+ return { buffer, mime }
93
+ }
94
+ }
95
+
96
+ // Now process with sharp (resize + ensure JPEG output)
97
+ const sharp = await tryLoadSharp()
98
+ if (!sharp) {
99
+ return { buffer: workingBuffer, mime: workingMime }
100
+ }
101
+
102
+ try {
103
+ const image = sharp(workingBuffer)
104
+ const metadata = await image.metadata()
105
+ const { width, height } = metadata
106
+
107
+ const needsResize = width && height && (width > MAX_DIMENSION || height > MAX_DIMENSION)
108
+
109
+ if (!needsResize) {
110
+ // Still convert to JPEG for consistency (unless already JPEG from HEIC conversion)
111
+ const outputBuffer = await image.jpeg({ quality: 85 }).toBuffer()
112
+ logger.log(`Converted image to JPEG: ${width}x${height} (${outputBuffer.length} bytes)`)
113
+ return { buffer: outputBuffer, mime: 'image/jpeg' }
114
+ }
115
+
116
+ // Resize and convert to JPEG
117
+ const outputBuffer = await image
118
+ .resize(MAX_DIMENSION, MAX_DIMENSION, {
119
+ fit: 'inside',
120
+ withoutEnlargement: true,
121
+ })
122
+ .jpeg({ quality: 85 })
123
+ .toBuffer()
124
+
125
+ logger.log(`Resized image: ${width}x${height} → max ${MAX_DIMENSION}px (${outputBuffer.length} bytes)`)
126
+
127
+ return { buffer: outputBuffer, mime: 'image/jpeg' }
128
+ } catch (error) {
129
+ logger.error('Failed to process image with sharp, using working buffer:', error)
130
+ return { buffer: workingBuffer, mime: workingMime }
131
+ }
132
+ }
@@ -5,11 +5,10 @@
5
5
  import type { Part } from '@opencode-ai/sdk/v2'
6
6
  import type { FilePartInput } from '@opencode-ai/sdk'
7
7
  import type { Message } from 'discord.js'
8
- import fs from 'node:fs'
9
- import path from 'node:path'
10
8
  import * as errore from 'errore'
11
9
  import { createLogger, LogPrefix } from './logger.js'
12
10
  import { FetchError } from './errors.js'
11
+ import { processImage } from './image-utils.js'
13
12
 
14
13
  // Generic message type compatible with both v1 and v2 SDK
15
14
  type GenericSessionMessage = {
@@ -17,8 +16,6 @@ type GenericSessionMessage = {
17
16
  parts: Part[]
18
17
  }
19
18
 
20
- const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments')
21
-
22
19
  const logger = createLogger(LogPrefix.FORMATTING)
23
20
 
24
21
  /**
@@ -192,11 +189,6 @@ export async function getFileAttachments(message: Message): Promise<FilePartInpu
192
189
  return []
193
190
  }
194
191
 
195
- // ensure tmp directory exists
196
- if (!fs.existsSync(ATTACHMENTS_DIR)) {
197
- fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true })
198
- }
199
-
200
192
  const results = await Promise.all(
201
193
  fileAttachments.map(async (attachment) => {
202
194
  const response = await errore.tryAsync({
@@ -212,17 +204,22 @@ export async function getFileAttachments(message: Message): Promise<FilePartInpu
212
204
  return null
213
205
  }
214
206
 
215
- const buffer = Buffer.from(await response.arrayBuffer())
216
- const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`)
217
- fs.writeFileSync(localPath, buffer)
207
+ const rawBuffer = Buffer.from(await response.arrayBuffer())
208
+ const originalMime = attachment.contentType || 'application/octet-stream'
209
+
210
+ // Process image (resize if needed, convert to JPEG)
211
+ const { buffer, mime } = await processImage(rawBuffer, originalMime)
212
+
213
+ const base64 = buffer.toString('base64')
214
+ const dataUrl = `data:${mime};base64,${base64}`
218
215
 
219
- logger.log(`Downloaded attachment to ${localPath}`)
216
+ logger.log(`Attachment ${attachment.name}: ${rawBuffer.length} ${buffer.length} bytes, ${mime}`)
220
217
 
221
218
  return {
222
219
  type: 'file' as const,
223
- mime: attachment.contentType || 'application/octet-stream',
220
+ mime,
224
221
  filename: attachment.name,
225
- url: localPath,
222
+ url: dataUrl,
226
223
  }
227
224
  }),
228
225
  )
@@ -44,6 +44,23 @@ const discordLogger = createLogger(LogPrefix.DISCORD)
44
44
 
45
45
  export const abortControllers = new Map<string, AbortController>()
46
46
 
47
+ // Built-in tools that are hidden in text-and-essential-tools verbosity mode.
48
+ // Essential tools (edits, bash, todos, tasks, custom MCP tools) are shown; these navigation/read tools are hidden.
49
+ const NON_ESSENTIAL_TOOLS = new Set([
50
+ 'read',
51
+ 'list',
52
+ 'glob',
53
+ 'grep',
54
+ 'todoread',
55
+ 'skill',
56
+ 'question',
57
+ 'webfetch',
58
+ ])
59
+
60
+ function isEssentialTool(toolName: string): boolean {
61
+ return !NON_ESSENTIAL_TOOLS.has(toolName)
62
+ }
63
+
47
64
  // Track multiple pending permissions per thread (keyed by permission ID)
48
65
  // OpenCode handles blocking/sequencing - we just need to track all pending permissions
49
66
  // to avoid duplicates and properly clean up on auto-reject
@@ -484,10 +501,21 @@ export async function handleOpencodeSession({
484
501
  }
485
502
 
486
503
  const sendPartMessage = async (part: Part) => {
504
+ const verbosity = getVerbosity()
487
505
  // In text-only mode, only send text parts (the ⬥ diamond messages)
488
- if (getVerbosity() === 'text-only' && part.type !== 'text') {
506
+ if (verbosity === 'text-only' && part.type !== 'text') {
489
507
  return
490
508
  }
509
+ // In text-and-essential-tools mode, show text + essential tools (edits, custom MCP tools)
510
+ if (verbosity === 'text-and-essential-tools') {
511
+ if (part.type === 'text') {
512
+ // text is always shown
513
+ } else if (part.type === 'tool' && isEssentialTool(part.tool)) {
514
+ // essential tools are shown
515
+ } else {
516
+ return
517
+ }
518
+ }
491
519
 
492
520
  const content = formatPart(part) + '\n\n'
493
521
  if (!content.trim() || content.length === 0) {
@@ -697,7 +725,7 @@ export async function handleOpencodeSession({
697
725
  agentSpawnCounts[agent] = (agentSpawnCounts[agent] || 0) + 1
698
726
  const label = `${agent}-${agentSpawnCounts[agent]}`
699
727
  subtaskSessions.set(childSessionId, { label, assistantMessageId: undefined })
700
- // Skip task messages in text-only mode
728
+ // Show task messages in tools-and-text and text-and-essential-tools modes
701
729
  if (getVerbosity() !== 'text-only') {
702
730
  const taskDisplay = `┣ task **${label}** _${description}_`
703
731
  await sendThreadMessage(thread, taskDisplay + '\n\n')
@@ -708,25 +736,38 @@ export async function handleOpencodeSession({
708
736
  return
709
737
  }
710
738
 
711
- if (part.type === 'tool' && part.state.status === 'completed' && getVerbosity() !== 'text-only') {
712
- const output = part.state.output || ''
713
- const outputTokens = Math.ceil(output.length / 4)
714
- const largeOutputThreshold = 3000
715
- if (outputTokens >= largeOutputThreshold) {
716
- const formattedTokens =
717
- outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens)
718
- const percentageSuffix = (() => {
719
- if (!modelContextLimit) {
720
- return ''
721
- }
722
- const pct = (outputTokens / modelContextLimit) * 100
723
- if (pct < 1) {
724
- return ''
725
- }
726
- return ` (${pct.toFixed(1)}%)`
727
- })()
728
- const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
729
- await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
739
+ // Show large output notifications for tools that are visible in current verbosity mode
740
+ if (part.type === 'tool' && part.state.status === 'completed') {
741
+ const showLargeOutput = (() => {
742
+ const verbosity = getVerbosity()
743
+ if (verbosity === 'text-only') {
744
+ return false
745
+ }
746
+ if (verbosity === 'text-and-essential-tools') {
747
+ return isEssentialTool(part.tool)
748
+ }
749
+ return true
750
+ })()
751
+ if (showLargeOutput) {
752
+ const output = part.state.output || ''
753
+ const outputTokens = Math.ceil(output.length / 4)
754
+ const largeOutputThreshold = 3000
755
+ if (outputTokens >= largeOutputThreshold) {
756
+ const formattedTokens =
757
+ outputTokens >= 1000 ? `${(outputTokens / 1000).toFixed(1)}k` : String(outputTokens)
758
+ const percentageSuffix = (() => {
759
+ if (!modelContextLimit) {
760
+ return ''
761
+ }
762
+ const pct = (outputTokens / modelContextLimit) * 100
763
+ if (pct < 1) {
764
+ return ''
765
+ }
766
+ return ` (${pct.toFixed(1)}%)`
767
+ })()
768
+ const chunk = `⬦ ${part.tool} returned ${formattedTokens} tokens${percentageSuffix}`
769
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
770
+ }
730
771
  }
731
772
  }
732
773
 
@@ -761,10 +802,17 @@ export async function handleOpencodeSession({
761
802
  part: Part,
762
803
  subtaskInfo: { label: string; assistantMessageId?: string },
763
804
  ) => {
805
+ const verbosity = getVerbosity()
764
806
  // In text-only mode, skip all subtask output (they're tool-related)
765
- if (getVerbosity() === 'text-only') {
807
+ if (verbosity === 'text-only') {
766
808
  return
767
809
  }
810
+ // In text-and-essential-tools mode, only show essential tools from subtasks
811
+ if (verbosity === 'text-and-essential-tools') {
812
+ if (part.type !== 'tool' || !isEssentialTool(part.tool)) {
813
+ return
814
+ }
815
+ }
768
816
  if (part.type === 'step-start' || part.type === 'step-finish') {
769
817
  return
770
818
  }
@@ -1275,11 +1323,12 @@ export async function handleOpencodeSession({
1275
1323
  images.map((img) => ({
1276
1324
  mime: img.mime,
1277
1325
  filename: img.filename,
1278
- url: img.url.slice(0, 100),
1326
+ urlPreview: img.url.slice(0, 50) + '...',
1279
1327
  })),
1280
1328
  )
1281
- const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n')
1282
- return `${prompt}\n\n**attached images:**\n${imagePathsList}`
1329
+ // Just list filenames, not the full base64 URLs (images are passed as separate parts)
1330
+ const imageList = images.map((img) => `- ${img.filename}`).join('\n')
1331
+ return `${prompt}\n\n**attached images:**\n${imageList}`
1283
1332
  })()
1284
1333
 
1285
1334
  const parts = [{ type: 'text' as const, text: promptWithImagePaths }, ...images]