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.
- package/dist/channel-management.js +15 -11
- package/dist/cli.js +9 -5
- package/dist/commands/add-project.js +2 -1
- package/dist/commands/create-new-project.js +2 -1
- package/dist/commands/verbosity.js +9 -3
- package/dist/discord-bot.js +11 -0
- package/dist/image-utils.js +107 -0
- package/dist/message-formatting.js +10 -13
- package/dist/session-handler.js +73 -24
- package/package.json +5 -2
- package/src/channel-management.ts +23 -14
- package/src/cli.ts +12 -5
- package/src/commands/add-project.ts +2 -1
- package/src/commands/create-new-project.ts +3 -2
- package/src/commands/verbosity.ts +9 -3
- package/src/database.ts +4 -1
- package/src/discord-bot.ts +14 -0
- package/src/image-utils.ts +132 -0
- package/src/message-formatting.ts +12 -15
- package/src/session-handler.ts +74 -25
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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('--
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
47
|
-
|
|
48
|
-
|
|
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,
|
package/dist/discord-bot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
171
|
+
mime,
|
|
175
172
|
filename: attachment.name,
|
|
176
|
-
url:
|
|
173
|
+
url: dataUrl,
|
|
177
174
|
};
|
|
178
175
|
}));
|
|
179
176
|
return results.filter((r) => r !== null);
|
package/dist/session-handler.js
CHANGED
|
@@ -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 (
|
|
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
|
-
//
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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 (
|
|
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
|
-
|
|
994
|
+
urlPreview: img.url.slice(0, 50) + '...',
|
|
947
995
|
})));
|
|
948
|
-
|
|
949
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
|
|
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
|
|
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('--
|
|
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
|
-
|
|
1144
|
-
|
|
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}
|
|
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}
|
|
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 =
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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()
|
package/src/discord-bot.ts
CHANGED
|
@@ -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
|
|
216
|
-
const
|
|
217
|
-
|
|
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(`
|
|
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
|
|
220
|
+
mime,
|
|
224
221
|
filename: attachment.name,
|
|
225
|
-
url:
|
|
222
|
+
url: dataUrl,
|
|
226
223
|
}
|
|
227
224
|
}),
|
|
228
225
|
)
|
package/src/session-handler.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
//
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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 (
|
|
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
|
-
|
|
1326
|
+
urlPreview: img.url.slice(0, 50) + '...',
|
|
1279
1327
|
})),
|
|
1280
1328
|
)
|
|
1281
|
-
|
|
1282
|
-
|
|
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]
|