kimaki 0.4.71 → 0.4.72
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/cli.js +3 -1
- package/dist/commands/queue.js +17 -46
- package/dist/condense-memory.js +33 -0
- package/dist/database.js +23 -21
- package/dist/db.test.js +24 -0
- package/dist/discord-bot.js +101 -12
- package/dist/discord-utils.js +29 -12
- package/dist/discord-utils.test.js +45 -1
- package/dist/interaction-handler.js +4 -4
- package/dist/logger.js +33 -14
- package/dist/opencode-plugin-loading.e2e.test.js +91 -0
- package/dist/opencode-plugin.js +7 -29
- package/dist/opencode-plugin.test.js +1 -1
- package/dist/opencode.js +12 -1
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/sentry.js +54 -1
- package/dist/session-handler.js +64 -2
- package/dist/system-message.js +5 -1
- package/dist/voice-handler.js +4 -3
- package/dist/voice.js +18 -5
- package/dist/voice.test.js +52 -8
- package/package.json +3 -6
- package/skills/batch/SKILL.md +87 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/src/cli.ts +4 -1
- package/src/commands/queue.ts +17 -61
- package/src/condense-memory.ts +36 -0
- package/src/database.ts +23 -21
- package/src/db.test.ts +29 -0
- package/src/discord-bot.ts +115 -13
- package/src/discord-utils.test.ts +54 -1
- package/src/discord-utils.ts +43 -20
- package/src/interaction-handler.ts +4 -13
- package/src/logger.ts +39 -16
- package/src/opencode-plugin-loading.e2e.test.ts +112 -0
- package/src/opencode-plugin.test.ts +1 -1
- package/src/opencode-plugin.ts +7 -30
- package/src/opencode.ts +14 -1
- package/src/privacy-sanitizer.ts +142 -0
- package/src/sentry.ts +55 -1
- package/src/session-handler.ts +107 -1
- package/src/system-message.ts +5 -1
- package/src/voice-handler.ts +7 -5
- package/src/voice.test.ts +54 -8
- package/src/voice.ts +29 -10
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuild
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import * as errore from 'errore';
|
|
19
|
-
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
|
|
19
|
+
import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './logger.js';
|
|
20
20
|
import { initSentry, notifyError } from './sentry.js';
|
|
21
21
|
import { archiveThread, uploadFilesToDiscord, stripMentions, } from './discord-utils.js';
|
|
22
22
|
import { spawn, execSync } from 'node:child_process';
|
|
@@ -1120,6 +1120,8 @@ cli
|
|
|
1120
1120
|
setDataDir(options.dataDir);
|
|
1121
1121
|
cliLogger.log(`Using data directory: ${getDataDir()}`);
|
|
1122
1122
|
}
|
|
1123
|
+
// Initialize file logging to <dataDir>/kimaki.log
|
|
1124
|
+
initLogFile(getDataDir());
|
|
1123
1125
|
if (options.verbosity) {
|
|
1124
1126
|
const validLevels = [
|
|
1125
1127
|
'tools-and-text',
|
package/dist/commands/queue.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { ChannelType, MessageFlags } from 'discord.js';
|
|
3
3
|
import { getThreadSession } from '../database.js';
|
|
4
4
|
import { resolveWorkingDirectory, sendThreadMessage, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
5
|
-
import { handleOpencodeSession, abortControllers, addToQueue, getQueueLength, clearQueue, } from '../session-handler.js';
|
|
5
|
+
import { handleOpencodeSession, abortControllers, addToQueue, getQueueLength, clearQueue, queueOrSendMessage, } from '../session-handler.js';
|
|
6
6
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
7
|
import { notifyError } from '../sentry.js';
|
|
8
8
|
import { registeredUserCommands } from '../config.js';
|
|
@@ -29,67 +29,38 @@ export async function handleQueueCommand({ command, appId, }) {
|
|
|
29
29
|
});
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
|
-
const
|
|
33
|
-
|
|
32
|
+
const result = await queueOrSendMessage({
|
|
33
|
+
thread: channel,
|
|
34
|
+
prompt: message,
|
|
35
|
+
userId: command.user.id,
|
|
36
|
+
username: command.user.displayName,
|
|
37
|
+
appId,
|
|
38
|
+
});
|
|
39
|
+
if (result.action === 'no-session') {
|
|
34
40
|
await command.reply({
|
|
35
41
|
content: 'No active session in this thread. Send a message directly instead.',
|
|
36
42
|
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
37
43
|
});
|
|
38
44
|
return;
|
|
39
45
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
abortControllers.delete(sessionId);
|
|
45
|
-
}
|
|
46
|
-
if (!hasActiveRequest) {
|
|
47
|
-
// No active request, send immediately
|
|
48
|
-
const resolved = await resolveWorkingDirectory({
|
|
49
|
-
channel: channel,
|
|
46
|
+
if (result.action === 'no-directory') {
|
|
47
|
+
await command.reply({
|
|
48
|
+
content: 'Could not determine project directory',
|
|
49
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
50
50
|
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
55
|
-
});
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (result.action === 'sent') {
|
|
58
54
|
await command.reply({
|
|
59
55
|
content: `» **${command.user.displayName}:** ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
|
|
60
56
|
flags: SILENT_MESSAGE_FLAGS,
|
|
61
57
|
});
|
|
62
|
-
logger.log(`[QUEUE] No active request, sending immediately in thread ${channel.id}`);
|
|
63
|
-
handleOpencodeSession({
|
|
64
|
-
prompt: message,
|
|
65
|
-
thread: channel,
|
|
66
|
-
projectDirectory: resolved.projectDirectory,
|
|
67
|
-
channelId: channel.parentId || channel.id,
|
|
68
|
-
appId,
|
|
69
|
-
}).catch(async (e) => {
|
|
70
|
-
logger.error(`[QUEUE] Failed to send message:`, e);
|
|
71
|
-
void notifyError(e, 'Queue: failed to send message');
|
|
72
|
-
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
73
|
-
await sendThreadMessage(channel, `✗ Failed: ${errorMsg.slice(0, 200)}`);
|
|
74
|
-
});
|
|
75
58
|
return;
|
|
76
59
|
}
|
|
77
|
-
// Add to queue
|
|
78
|
-
const queuePosition = addToQueue({
|
|
79
|
-
threadId: channel.id,
|
|
80
|
-
message: {
|
|
81
|
-
prompt: message,
|
|
82
|
-
userId: command.user.id,
|
|
83
|
-
username: command.user.displayName,
|
|
84
|
-
queuedAt: Date.now(),
|
|
85
|
-
appId,
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
60
|
await command.reply({
|
|
89
|
-
content:
|
|
61
|
+
content: `Message queued (position: ${result.position}). Will be sent after current response.`,
|
|
90
62
|
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
91
63
|
});
|
|
92
|
-
logger.log(`[QUEUE] User ${command.user.displayName} queued message in thread ${channel.id}`);
|
|
93
64
|
}
|
|
94
65
|
export async function handleClearQueueCommand({ command, }) {
|
|
95
66
|
const channel = command.channel;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Utility to condense MEMORY.md into a line-numbered table of contents.
|
|
2
|
+
// Separated from opencode-plugin.ts because OpenCode's plugin loader calls
|
|
3
|
+
// every exported function in the module as a plugin initializer — exporting
|
|
4
|
+
// this utility from the plugin entry file caused it to be invoked with a
|
|
5
|
+
// PluginInput object instead of a string, crashing inside marked's Lexer.
|
|
6
|
+
import { Lexer } from 'marked';
|
|
7
|
+
/**
|
|
8
|
+
* Condense MEMORY.md into a line-numbered table of contents.
|
|
9
|
+
* Parses markdown AST with marked's Lexer, emits each heading prefixed by
|
|
10
|
+
* its source line number, and collapses non-heading content to `...`.
|
|
11
|
+
* The agent can then use Read with offset/limit to read specific sections.
|
|
12
|
+
*/
|
|
13
|
+
export function condenseMemoryMd(content) {
|
|
14
|
+
const tokens = new Lexer().lex(content);
|
|
15
|
+
const lines = [];
|
|
16
|
+
let charOffset = 0;
|
|
17
|
+
let lastWasEllipsis = false;
|
|
18
|
+
for (const token of tokens) {
|
|
19
|
+
// Compute 1-based line number from character offset
|
|
20
|
+
const lineNumber = content.slice(0, charOffset).split('\n').length;
|
|
21
|
+
if (token.type === 'heading') {
|
|
22
|
+
const prefix = '#'.repeat(token.depth);
|
|
23
|
+
lines.push(`${lineNumber}: ${prefix} ${token.text}`);
|
|
24
|
+
lastWasEllipsis = false;
|
|
25
|
+
}
|
|
26
|
+
else if (!lastWasEllipsis) {
|
|
27
|
+
lines.push('...');
|
|
28
|
+
lastWasEllipsis = true;
|
|
29
|
+
}
|
|
30
|
+
charOffset += token.raw.length;
|
|
31
|
+
}
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
package/dist/database.js
CHANGED
|
@@ -460,27 +460,29 @@ export async function getThreadWorktree(threadId) {
|
|
|
460
460
|
*/
|
|
461
461
|
export async function createPendingWorktree({ threadId, worktreeName, projectDirectory, }) {
|
|
462
462
|
const prisma = await getPrisma();
|
|
463
|
-
await prisma
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
463
|
+
await prisma.$transaction([
|
|
464
|
+
prisma.thread_sessions.upsert({
|
|
465
|
+
where: { thread_id: threadId },
|
|
466
|
+
create: { thread_id: threadId, session_id: '' },
|
|
467
|
+
update: {},
|
|
468
|
+
}),
|
|
469
|
+
prisma.thread_worktrees.upsert({
|
|
470
|
+
where: { thread_id: threadId },
|
|
471
|
+
create: {
|
|
472
|
+
thread_id: threadId,
|
|
473
|
+
worktree_name: worktreeName,
|
|
474
|
+
project_directory: projectDirectory,
|
|
475
|
+
status: 'pending',
|
|
476
|
+
},
|
|
477
|
+
update: {
|
|
478
|
+
worktree_name: worktreeName,
|
|
479
|
+
project_directory: projectDirectory,
|
|
480
|
+
status: 'pending',
|
|
481
|
+
worktree_directory: null,
|
|
482
|
+
error_message: null,
|
|
483
|
+
},
|
|
484
|
+
}),
|
|
485
|
+
]);
|
|
484
486
|
}
|
|
485
487
|
/**
|
|
486
488
|
* Mark a worktree as ready with its directory.
|
package/dist/db.test.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Auto-isolated via VITEST guards in config.ts (temp data dir) and db.ts (clears KIMAKI_DB_URL).
|
|
3
3
|
import { afterAll, describe, expect, test } from 'vitest';
|
|
4
4
|
import { getPrisma, closePrisma } from './db.js';
|
|
5
|
+
import { createPendingWorktree } from './database.js';
|
|
5
6
|
afterAll(async () => {
|
|
6
7
|
await closePrisma();
|
|
7
8
|
});
|
|
@@ -22,4 +23,27 @@ describe('getPrisma', () => {
|
|
|
22
23
|
where: { thread_id: 'test-thread-123' },
|
|
23
24
|
});
|
|
24
25
|
});
|
|
26
|
+
test('createPendingWorktree creates parent and child rows', async () => {
|
|
27
|
+
const prisma = await getPrisma();
|
|
28
|
+
const threadId = `test-worktree-${Date.now()}`;
|
|
29
|
+
await createPendingWorktree({
|
|
30
|
+
threadId,
|
|
31
|
+
worktreeName: 'regression-worktree',
|
|
32
|
+
projectDirectory: '/tmp/regression-project',
|
|
33
|
+
});
|
|
34
|
+
const session = await prisma.thread_sessions.findUnique({
|
|
35
|
+
where: { thread_id: threadId },
|
|
36
|
+
});
|
|
37
|
+
expect(session).toBeTruthy();
|
|
38
|
+
expect(session?.session_id).toBe('');
|
|
39
|
+
const worktree = await prisma.thread_worktrees.findUnique({
|
|
40
|
+
where: { thread_id: threadId },
|
|
41
|
+
});
|
|
42
|
+
expect(worktree).toBeTruthy();
|
|
43
|
+
expect(worktree?.worktree_name).toBe('regression-worktree');
|
|
44
|
+
expect(worktree?.project_directory).toBe('/tmp/regression-project');
|
|
45
|
+
expect(worktree?.status).toBe('pending');
|
|
46
|
+
await prisma.thread_worktrees.delete({ where: { thread_id: threadId } });
|
|
47
|
+
await prisma.thread_sessions.delete({ where: { thread_id: threadId } });
|
|
48
|
+
});
|
|
25
49
|
});
|
package/dist/discord-bot.js
CHANGED
|
@@ -6,14 +6,14 @@ import { initializeOpencodeForDirectory, getOpencodeServers, } from './opencode.
|
|
|
6
6
|
import { formatWorktreeName } from './commands/worktree.js';
|
|
7
7
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
8
8
|
import { createWorktreeWithSubmodules } from './worktree-utils.js';
|
|
9
|
-
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
|
|
9
|
+
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
|
|
10
10
|
import { getOpencodeSystemMessage, } from './system-message.js';
|
|
11
11
|
import yaml from 'js-yaml';
|
|
12
12
|
import { getFileAttachments, getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
13
13
|
import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
|
|
14
14
|
import { voiceConnections, cleanupVoiceConnection, processVoiceAttachment, registerVoiceStateHandler, } from './voice-handler.js';
|
|
15
15
|
import { getCompactSessionContext, getLastSessionId } from './markdown.js';
|
|
16
|
-
import { handleOpencodeSession, signalThreadInterrupt, } from './session-handler.js';
|
|
16
|
+
import { handleOpencodeSession, signalThreadInterrupt, queueOrSendMessage, abortControllers, } from './session-handler.js';
|
|
17
17
|
import { runShellCommand } from './commands/run-command.js';
|
|
18
18
|
import { registerInteractionHandler } from './interaction-handler.js';
|
|
19
19
|
import { stopHranaServer } from './hrana-server.js';
|
|
@@ -303,10 +303,41 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
303
303
|
}
|
|
304
304
|
// Chain onto per-thread queue so messages (voice transcription + text)
|
|
305
305
|
// are processed in Discord arrival order, not completion order.
|
|
306
|
+
const hasVoiceAttachment = message.attachments.some((a) => {
|
|
307
|
+
return a.contentType?.startsWith('audio/');
|
|
308
|
+
});
|
|
306
309
|
const prev = threadMessageQueue.get(thread.id);
|
|
307
|
-
|
|
310
|
+
// Snapshot active request state NOW, before prev task finishes.
|
|
311
|
+
// Voice messages skip the eager interrupt so the session stays alive during
|
|
312
|
+
// transcription. But processThreadMessage is serialized behind prev, so by
|
|
313
|
+
// the time it runs the prev task may have finished and the controller is gone.
|
|
314
|
+
// This snapshot lets queueOrSendMessage know there WAS an active request
|
|
315
|
+
// when the voice message arrived, so it should queue even if the controller
|
|
316
|
+
// is no longer active.
|
|
317
|
+
// Conservative: if prev exists, something is actively being processed, so
|
|
318
|
+
// we treat it as having an active request (avoids race where the async
|
|
319
|
+
// getThreadSession call lets the prev task finish first).
|
|
320
|
+
const hadActiveRequestOnArrival = await (async () => {
|
|
321
|
+
if (!hasVoiceAttachment) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
if (prev) {
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
const sid = await getThreadSession(thread.id);
|
|
328
|
+
if (!sid) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
const controller = abortControllers.get(sid);
|
|
332
|
+
return Boolean(controller && !controller.signal.aborted);
|
|
333
|
+
})();
|
|
334
|
+
if (prev && !hasVoiceAttachment) {
|
|
308
335
|
// Another message is being processed — abort it immediately so this
|
|
309
336
|
// queued message can start as soon as possible.
|
|
337
|
+
// Voice messages are excluded: they need transcription first to detect
|
|
338
|
+
// "queue this message" intent. Interrupting before transcription would
|
|
339
|
+
// abort the running session, making queueOrSendMessage see no active
|
|
340
|
+
// request and send immediately instead of queueing.
|
|
310
341
|
const sdkDirectory = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
311
342
|
? worktreeInfo.worktree_directory
|
|
312
343
|
: projectDirectory;
|
|
@@ -335,14 +366,18 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
335
366
|
return;
|
|
336
367
|
}
|
|
337
368
|
let prompt = resolveMentions(message);
|
|
338
|
-
const
|
|
369
|
+
const voiceResult = await processVoiceAttachment({
|
|
339
370
|
message,
|
|
340
371
|
thread,
|
|
341
372
|
projectDirectory,
|
|
342
373
|
appId: currentAppId,
|
|
343
374
|
});
|
|
344
|
-
if (
|
|
345
|
-
prompt = `Voice message transcription from Discord user:\n\n${transcription}`;
|
|
375
|
+
if (voiceResult) {
|
|
376
|
+
prompt = `Voice message transcription from Discord user:\n\n${voiceResult.transcription}`;
|
|
377
|
+
}
|
|
378
|
+
// If voice transcription failed and there's no text content, bail out
|
|
379
|
+
if (hasVoiceAttachment && !voiceResult && !prompt.trim()) {
|
|
380
|
+
return;
|
|
346
381
|
}
|
|
347
382
|
const starterMessage = await thread
|
|
348
383
|
.fetchStarterMessage()
|
|
@@ -426,7 +461,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
426
461
|
void notifyError(e, 'Failed to get session context');
|
|
427
462
|
}
|
|
428
463
|
}
|
|
429
|
-
const
|
|
464
|
+
const voiceResult = await processVoiceAttachment({
|
|
430
465
|
message,
|
|
431
466
|
thread,
|
|
432
467
|
projectDirectory,
|
|
@@ -434,8 +469,58 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
434
469
|
currentSessionContext,
|
|
435
470
|
lastSessionContext,
|
|
436
471
|
});
|
|
437
|
-
if (
|
|
438
|
-
messageContent = `Voice message transcription from Discord user:\n\n${transcription}`;
|
|
472
|
+
if (voiceResult) {
|
|
473
|
+
messageContent = `Voice message transcription from Discord user:\n\n${voiceResult.transcription}`;
|
|
474
|
+
}
|
|
475
|
+
// If voice transcription failed (returned null) and there's no text content,
|
|
476
|
+
// bail out — don't fire deferred interrupt or send an empty prompt.
|
|
477
|
+
if (hasVoiceAttachment && !voiceResult && !messageContent.trim()) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// If the transcription model detected "queue this message" intent,
|
|
481
|
+
// use queueOrSendMessage instead of sending immediately.
|
|
482
|
+
if (voiceResult?.queueMessage) {
|
|
483
|
+
const fileAttachments = await getFileAttachments(message);
|
|
484
|
+
const textAttachmentsContent = await getTextAttachments(message);
|
|
485
|
+
const promptWithAttachments = textAttachmentsContent
|
|
486
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
487
|
+
: messageContent;
|
|
488
|
+
const username = cliInjectedUsername ||
|
|
489
|
+
message.member?.displayName ||
|
|
490
|
+
message.author.displayName;
|
|
491
|
+
const result = await queueOrSendMessage({
|
|
492
|
+
thread,
|
|
493
|
+
prompt: promptWithAttachments,
|
|
494
|
+
userId: isCliInjectedPrompt
|
|
495
|
+
? cliInjectedUserId || message.author.id
|
|
496
|
+
: message.author.id,
|
|
497
|
+
username,
|
|
498
|
+
appId: currentAppId,
|
|
499
|
+
images: fileAttachments,
|
|
500
|
+
forceQueue: hadActiveRequestOnArrival,
|
|
501
|
+
});
|
|
502
|
+
if (result.action === 'queued') {
|
|
503
|
+
await sendThreadMessage(thread, `Queued (position: ${result.position}). Will be sent after current response.`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (result.action === 'sent') {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// no-session / no-directory: fall through to normal handleOpencodeSession flow
|
|
510
|
+
}
|
|
511
|
+
// For voice messages without queue intent, we deferred the interrupt
|
|
512
|
+
// until after transcription (to preserve active-request state for queue
|
|
513
|
+
// detection). Now that we know it's not a queue message, signal the
|
|
514
|
+
// interrupt so the running session aborts before the new prompt is sent.
|
|
515
|
+
if (hasVoiceAttachment && !voiceResult?.queueMessage) {
|
|
516
|
+
const sdkDirectory = worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
517
|
+
? worktreeInfo.worktree_directory
|
|
518
|
+
: projectDirectory;
|
|
519
|
+
signalThreadInterrupt({
|
|
520
|
+
threadId: thread.id,
|
|
521
|
+
serverDirectory: projectDirectory,
|
|
522
|
+
sdkDirectory,
|
|
523
|
+
});
|
|
439
524
|
}
|
|
440
525
|
const fileAttachments = await getFileAttachments(message);
|
|
441
526
|
const textAttachmentsContent = await getTextAttachments(message);
|
|
@@ -567,15 +652,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
567
652
|
}
|
|
568
653
|
}
|
|
569
654
|
let messageContent = resolveMentions(message);
|
|
570
|
-
const
|
|
655
|
+
const voiceResult = await processVoiceAttachment({
|
|
571
656
|
message,
|
|
572
657
|
thread,
|
|
573
658
|
projectDirectory: sessionDirectory,
|
|
574
659
|
isNewThread: true,
|
|
575
660
|
appId: currentAppId,
|
|
576
661
|
});
|
|
577
|
-
if (
|
|
578
|
-
messageContent = `Voice message transcription from Discord user:\n\n${transcription}`;
|
|
662
|
+
if (voiceResult) {
|
|
663
|
+
messageContent = `Voice message transcription from Discord user:\n\n${voiceResult.transcription}`;
|
|
664
|
+
}
|
|
665
|
+
// If voice transcription failed and there's no text content, bail out
|
|
666
|
+
if (hasVoice && !voiceResult && !messageContent.trim()) {
|
|
667
|
+
return;
|
|
579
668
|
}
|
|
580
669
|
const fileAttachments = await getFileAttachments(message);
|
|
581
670
|
const textAttachmentsContent = await getTextAttachments(message);
|
package/dist/discord-utils.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Discord-specific utility functions.
|
|
2
2
|
// Handles markdown splitting for Discord's 2000-char limit, code block escaping,
|
|
3
3
|
// thread message sending, and channel metadata extraction from topic tags.
|
|
4
|
-
import { ChannelType, MessageFlags, PermissionsBitField, } from 'discord.js';
|
|
4
|
+
import { ChannelType, GuildMember, MessageFlags, PermissionsBitField, } from 'discord.js';
|
|
5
5
|
import { REST, Routes } from 'discord.js';
|
|
6
6
|
import { Lexer } from 'marked';
|
|
7
7
|
import { splitTablesFromMarkdown } from './format-tables.js';
|
|
@@ -20,25 +20,42 @@ const discordLogger = createLogger(LogPrefix.DISCORD);
|
|
|
20
20
|
* - Server owner, Administrator, Manage Server, or "Kimaki" role (case-insensitive).
|
|
21
21
|
* Returns false if member is null or has the "no-kimaki" role (overrides all).
|
|
22
22
|
*/
|
|
23
|
-
export function hasKimakiBotPermission(member) {
|
|
23
|
+
export function hasKimakiBotPermission(member, guild) {
|
|
24
24
|
if (!member) {
|
|
25
25
|
return false;
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
// instead of a hydrated GuildMember class instance, so roles.cache may not exist
|
|
29
|
-
if (!member.roles?.cache) {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
const hasNoKimakiRole = member.roles.cache.some((role) => role.name.toLowerCase() === 'no-kimaki');
|
|
27
|
+
const hasNoKimakiRole = hasRoleByName(member, 'no-kimaki', guild);
|
|
33
28
|
if (hasNoKimakiRole) {
|
|
34
29
|
return false;
|
|
35
30
|
}
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
31
|
+
const memberPermissions = member instanceof GuildMember
|
|
32
|
+
? member.permissions
|
|
33
|
+
: new PermissionsBitField(BigInt(member.permissions));
|
|
34
|
+
const ownerId = member instanceof GuildMember ? member.guild.ownerId : guild?.ownerId;
|
|
35
|
+
const memberId = member instanceof GuildMember ? member.id : member.user.id;
|
|
36
|
+
const isOwner = ownerId ? memberId === ownerId : false;
|
|
37
|
+
const isAdmin = memberPermissions.has(PermissionsBitField.Flags.Administrator);
|
|
38
|
+
const canManageServer = memberPermissions.has(PermissionsBitField.Flags.ManageGuild);
|
|
39
|
+
const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
|
|
40
40
|
return isOwner || isAdmin || canManageServer || hasKimakiRole;
|
|
41
41
|
}
|
|
42
|
+
function hasRoleByName(member, roleName, guild) {
|
|
43
|
+
const target = roleName.toLowerCase();
|
|
44
|
+
if (member instanceof GuildMember) {
|
|
45
|
+
return member.roles.cache.some((role) => role.name.toLowerCase() === target);
|
|
46
|
+
}
|
|
47
|
+
if (!guild) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const roleIds = Array.isArray(member.roles) ? member.roles : [];
|
|
51
|
+
for (const roleId of roleIds) {
|
|
52
|
+
const role = guild.roles.cache.get(roleId);
|
|
53
|
+
if (role?.name.toLowerCase() === target) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
42
59
|
/**
|
|
43
60
|
* Check if the member has the "no-kimaki" role that blocks bot access.
|
|
44
61
|
* Separate from hasKimakiBotPermission so callers can show a specific error message.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { PermissionsBitField } from 'discord.js';
|
|
1
2
|
import { describe, expect, test } from 'vitest';
|
|
2
|
-
import { splitMarkdownForDiscord } from './discord-utils.js';
|
|
3
|
+
import { hasKimakiBotPermission, splitMarkdownForDiscord } from './discord-utils.js';
|
|
3
4
|
describe('splitMarkdownForDiscord', () => {
|
|
4
5
|
test('never returns chunks over the max length with code fences', () => {
|
|
5
6
|
const maxLength = 2000;
|
|
@@ -69,3 +70,46 @@ describe('splitMarkdownForDiscord', () => {
|
|
|
69
70
|
`);
|
|
70
71
|
});
|
|
71
72
|
});
|
|
73
|
+
describe('hasKimakiBotPermission', () => {
|
|
74
|
+
test('allows API interaction member when kimaki role exists', () => {
|
|
75
|
+
const kimakiRoleId = '111';
|
|
76
|
+
const guild = {
|
|
77
|
+
ownerId: 'owner-id',
|
|
78
|
+
roles: {
|
|
79
|
+
cache: new Map([
|
|
80
|
+
[kimakiRoleId, { id: kimakiRoleId, name: 'Kimaki' }],
|
|
81
|
+
]),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const member = {
|
|
85
|
+
user: { id: 'member-id' },
|
|
86
|
+
permissions: '0',
|
|
87
|
+
roles: [kimakiRoleId],
|
|
88
|
+
};
|
|
89
|
+
expect(hasKimakiBotPermission(member, guild)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
test('allows API interaction member with ManageGuild permission', () => {
|
|
92
|
+
const guild = {
|
|
93
|
+
ownerId: 'owner-id',
|
|
94
|
+
roles: { cache: new Map() },
|
|
95
|
+
};
|
|
96
|
+
const member = {
|
|
97
|
+
user: { id: 'member-id' },
|
|
98
|
+
permissions: PermissionsBitField.Flags.ManageGuild.toString(),
|
|
99
|
+
roles: [],
|
|
100
|
+
};
|
|
101
|
+
expect(hasKimakiBotPermission(member, guild)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
test('denies API interaction member with no role, owner, or admin rights', () => {
|
|
104
|
+
const guild = {
|
|
105
|
+
ownerId: 'owner-id',
|
|
106
|
+
roles: { cache: new Map() },
|
|
107
|
+
};
|
|
108
|
+
const member = {
|
|
109
|
+
user: { id: 'member-id' },
|
|
110
|
+
permissions: '0',
|
|
111
|
+
roles: [],
|
|
112
|
+
};
|
|
113
|
+
expect(hasKimakiBotPermission(member, guild)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -72,7 +72,7 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
72
72
|
}
|
|
73
73
|
if (interaction.isChatInputCommand()) {
|
|
74
74
|
interactionLogger.log(`[COMMAND] Processing: ${interaction.commandName}`);
|
|
75
|
-
if (!hasKimakiBotPermission(interaction.member)) {
|
|
75
|
+
if (!hasKimakiBotPermission(interaction.member, interaction.guild)) {
|
|
76
76
|
await interaction.reply({
|
|
77
77
|
content: `You don't have permission to use this command.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
78
78
|
flags: MessageFlags.Ephemeral,
|
|
@@ -204,7 +204,7 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
204
204
|
return;
|
|
205
205
|
}
|
|
206
206
|
if (interaction.isButton()) {
|
|
207
|
-
if (!hasKimakiBotPermission(interaction.member)) {
|
|
207
|
+
if (!hasKimakiBotPermission(interaction.member, interaction.guild)) {
|
|
208
208
|
await interaction.reply({
|
|
209
209
|
content: `You don't have permission to use this.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
210
210
|
flags: MessageFlags.Ephemeral,
|
|
@@ -233,7 +233,7 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
233
233
|
return;
|
|
234
234
|
}
|
|
235
235
|
if (interaction.isStringSelectMenu()) {
|
|
236
|
-
if (!hasKimakiBotPermission(interaction.member)) {
|
|
236
|
+
if (!hasKimakiBotPermission(interaction.member, interaction.guild)) {
|
|
237
237
|
await interaction.reply({
|
|
238
238
|
content: `You don't have permission to use this.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
239
239
|
flags: MessageFlags.Ephemeral,
|
|
@@ -280,7 +280,7 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
280
280
|
return;
|
|
281
281
|
}
|
|
282
282
|
if (interaction.isModalSubmit()) {
|
|
283
|
-
if (!hasKimakiBotPermission(interaction.member)) {
|
|
283
|
+
if (!hasKimakiBotPermission(interaction.member, interaction.guild)) {
|
|
284
284
|
await interaction.reply({
|
|
285
285
|
content: `You don't have permission to use this.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
286
286
|
flags: MessageFlags.Ephemeral,
|
package/dist/logger.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// output interleaving from concurrent async operations.
|
|
4
4
|
import { log as clackLog } from '@clack/prompts';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import path
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import path from 'node:path';
|
|
8
7
|
import util from 'node:util';
|
|
9
8
|
import pc from 'picocolors';
|
|
9
|
+
import { sanitizeSensitiveText, sanitizeUnknownValue } from './privacy-sanitizer.js';
|
|
10
10
|
// All known log prefixes - add new ones here to keep alignment consistent
|
|
11
11
|
export const LogPrefix = {
|
|
12
12
|
ABORT: 'ABORT',
|
|
@@ -51,36 +51,55 @@ export const LogPrefix = {
|
|
|
51
51
|
};
|
|
52
52
|
// compute max length from all known prefixes for alignment
|
|
53
53
|
const MAX_PREFIX_LENGTH = Math.max(...Object.values(LogPrefix).map((p) => p.length));
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
// Log file path is set by initLogFile() after the data directory is known.
|
|
55
|
+
// Before initLogFile() is called, file logging is skipped.
|
|
56
|
+
let logFilePath = null;
|
|
57
|
+
/**
|
|
58
|
+
* Initialize file logging. Call this after setDataDir() so the log file
|
|
59
|
+
* is written to `<dataDir>/kimaki.log`. The log file is truncated on
|
|
60
|
+
* every bot startup so it contains only the current run's logs.
|
|
61
|
+
*/
|
|
62
|
+
export function initLogFile(dataDir) {
|
|
63
|
+
logFilePath = path.join(dataDir, 'kimaki.log');
|
|
60
64
|
const logDir = path.dirname(logFilePath);
|
|
61
65
|
if (!fs.existsSync(logDir)) {
|
|
62
66
|
fs.mkdirSync(logDir, { recursive: true });
|
|
63
67
|
}
|
|
64
68
|
fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`);
|
|
65
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Set the log file path without truncating. Use this in child processes
|
|
72
|
+
* (like the opencode plugin) that should append to the same log file
|
|
73
|
+
* the bot process already created with initLogFile().
|
|
74
|
+
*/
|
|
75
|
+
export function setLogFilePath(dataDir) {
|
|
76
|
+
logFilePath = path.join(dataDir, 'kimaki.log');
|
|
77
|
+
}
|
|
78
|
+
export function getLogFilePath() {
|
|
79
|
+
return logFilePath;
|
|
80
|
+
}
|
|
66
81
|
function formatArg(arg) {
|
|
67
82
|
if (typeof arg === 'string') {
|
|
68
|
-
return arg;
|
|
83
|
+
return sanitizeSensitiveText(arg, { redactPaths: false });
|
|
69
84
|
}
|
|
70
|
-
|
|
85
|
+
const safeArg = sanitizeUnknownValue(arg, { redactPaths: false });
|
|
86
|
+
return util.inspect(safeArg, { colors: true, depth: 4 });
|
|
71
87
|
}
|
|
72
88
|
export function formatErrorWithStack(error) {
|
|
73
89
|
if (error instanceof Error) {
|
|
74
|
-
return error.stack ?? `${error.name}: ${error.message}
|
|
90
|
+
return sanitizeSensitiveText(error.stack ?? `${error.name}: ${error.message}`, { redactPaths: false });
|
|
75
91
|
}
|
|
76
92
|
if (typeof error === 'string') {
|
|
77
|
-
return error;
|
|
93
|
+
return sanitizeSensitiveText(error, { redactPaths: false });
|
|
78
94
|
}
|
|
79
95
|
// Keep this stable and safe for unknown values (handles circular structures).
|
|
80
|
-
|
|
96
|
+
const safeError = sanitizeUnknownValue(error, { redactPaths: false });
|
|
97
|
+
return sanitizeSensitiveText(util.inspect(safeError, { colors: false, depth: 4 }), {
|
|
98
|
+
redactPaths: false,
|
|
99
|
+
});
|
|
81
100
|
}
|
|
82
101
|
function writeToFile(level, prefix, args) {
|
|
83
|
-
if (!
|
|
102
|
+
if (!logFilePath) {
|
|
84
103
|
return;
|
|
85
104
|
}
|
|
86
105
|
const timestamp = new Date().toISOString();
|