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.
Files changed (46) hide show
  1. package/dist/cli.js +3 -1
  2. package/dist/commands/queue.js +17 -46
  3. package/dist/condense-memory.js +33 -0
  4. package/dist/database.js +23 -21
  5. package/dist/db.test.js +24 -0
  6. package/dist/discord-bot.js +101 -12
  7. package/dist/discord-utils.js +29 -12
  8. package/dist/discord-utils.test.js +45 -1
  9. package/dist/interaction-handler.js +4 -4
  10. package/dist/logger.js +33 -14
  11. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  12. package/dist/opencode-plugin.js +7 -29
  13. package/dist/opencode-plugin.test.js +1 -1
  14. package/dist/opencode.js +12 -1
  15. package/dist/privacy-sanitizer.js +105 -0
  16. package/dist/sentry.js +54 -1
  17. package/dist/session-handler.js +64 -2
  18. package/dist/system-message.js +5 -1
  19. package/dist/voice-handler.js +4 -3
  20. package/dist/voice.js +18 -5
  21. package/dist/voice.test.js +52 -8
  22. package/package.json +3 -6
  23. package/skills/batch/SKILL.md +87 -0
  24. package/skills/security-review/SKILL.md +208 -0
  25. package/skills/simplify/SKILL.md +58 -0
  26. package/src/cli.ts +4 -1
  27. package/src/commands/queue.ts +17 -61
  28. package/src/condense-memory.ts +36 -0
  29. package/src/database.ts +23 -21
  30. package/src/db.test.ts +29 -0
  31. package/src/discord-bot.ts +115 -13
  32. package/src/discord-utils.test.ts +54 -1
  33. package/src/discord-utils.ts +43 -20
  34. package/src/interaction-handler.ts +4 -13
  35. package/src/logger.ts +39 -16
  36. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  37. package/src/opencode-plugin.test.ts +1 -1
  38. package/src/opencode-plugin.ts +7 -30
  39. package/src/opencode.ts +14 -1
  40. package/src/privacy-sanitizer.ts +142 -0
  41. package/src/sentry.ts +55 -1
  42. package/src/session-handler.ts +107 -1
  43. package/src/system-message.ts +5 -1
  44. package/src/voice-handler.ts +7 -5
  45. package/src/voice.test.ts +54 -8
  46. 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',
@@ -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 sessionId = await getThreadSession(channel.id);
33
- if (!sessionId) {
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
- // Check if there's an active request running
41
- const existingController = abortControllers.get(sessionId);
42
- const hasActiveRequest = Boolean(existingController && !existingController.signal.aborted);
43
- if (existingController && existingController.signal.aborted) {
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
- if (!resolved) {
52
- await command.reply({
53
- content: 'Could not determine project directory',
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: `✅ Message queued (position: ${queuePosition}). Will be sent after current response.`,
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.thread_sessions.upsert({
464
- where: { thread_id: threadId },
465
- create: { thread_id: threadId, session_id: '' },
466
- update: {},
467
- });
468
- await prisma.thread_worktrees.upsert({
469
- where: { thread_id: threadId },
470
- create: {
471
- thread_id: threadId,
472
- worktree_name: worktreeName,
473
- project_directory: projectDirectory,
474
- status: 'pending',
475
- },
476
- update: {
477
- worktree_name: worktreeName,
478
- project_directory: projectDirectory,
479
- status: 'pending',
480
- worktree_directory: null,
481
- error_message: null,
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
  });
@@ -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
- if (prev) {
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 transcription = await processVoiceAttachment({
369
+ const voiceResult = await processVoiceAttachment({
339
370
  message,
340
371
  thread,
341
372
  projectDirectory,
342
373
  appId: currentAppId,
343
374
  });
344
- if (transcription) {
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 transcription = await processVoiceAttachment({
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 (transcription) {
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 transcription = await processVoiceAttachment({
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 (transcription) {
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);
@@ -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
- // interaction.member can be an APIInteractionGuildMember (plain object)
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 isOwner = member.id === member.guild.ownerId;
37
- const isAdmin = member.permissions.has(PermissionsBitField.Flags.Administrator);
38
- const canManageServer = member.permissions.has(PermissionsBitField.Flags.ManageGuild);
39
- const hasKimakiRole = member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
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, { dirname } from 'node: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
- const __filename = fileURLToPath(import.meta.url);
55
- const __dirname = dirname(__filename);
56
- const isDev = !__dirname.includes('node_modules');
57
- const logFilePath = path.join(__dirname, '..', 'kimaki.log');
58
- // reset log file on startup in dev mode
59
- if (isDev) {
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
- return util.inspect(arg, { colors: true, depth: 4 });
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
- return util.inspect(error, { colors: false, depth: 4 });
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 (!isDev) {
102
+ if (!logFilePath) {
84
103
  return;
85
104
  }
86
105
  const timestamp = new Date().toISOString();