@brianli/kimaki 0.4.72-brianli.1
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/bin.js +2 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/bin.js +87 -0
- package/dist/bot-token.js +121 -0
- package/dist/bot-token.test.js +134 -0
- package/dist/channel-management.js +101 -0
- package/dist/cli-parsing.test.js +89 -0
- package/dist/cli.js +2529 -0
- package/dist/commands/abort.js +82 -0
- package/dist/commands/action-buttons.js +257 -0
- package/dist/commands/add-project.js +114 -0
- package/dist/commands/agent.js +291 -0
- package/dist/commands/ask-question.js +223 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +118 -0
- package/dist/commands/diff.js +128 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +217 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +490 -0
- package/dist/commands/mention-mode.js +51 -0
- package/dist/commands/merge-worktree.js +124 -0
- package/dist/commands/model.js +694 -0
- package/dist/commands/permissions.js +163 -0
- package/dist/commands/queue.js +217 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +116 -0
- package/dist/commands/resume.js +159 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +192 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +159 -0
- package/dist/commands/unset-model.js +152 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +148 -0
- package/dist/commands/verbosity.js +60 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +299 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +110 -0
- package/dist/database.js +1050 -0
- package/dist/db.js +159 -0
- package/dist/db.test.js +49 -0
- package/dist/discord-api.js +28 -0
- package/dist/discord-auth.js +231 -0
- package/dist/discord-auth.test.js +80 -0
- package/dist/discord-bot.js +997 -0
- package/dist/discord-utils.js +560 -0
- package/dist/discord-utils.test.js +115 -0
- package/dist/errors.js +167 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +122 -0
- package/dist/format-tables.test.js +199 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +117 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-consumer.js +232 -0
- package/dist/gateway-consumer.test.js +18 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +35 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +30 -0
- package/dist/generated/internal/class.js +41 -0
- package/dist/generated/internal/prismaNamespace.js +239 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +95 -0
- package/dist/hrana-server.js +416 -0
- package/dist/hrana-server.test.js +368 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +327 -0
- package/dist/ipc-polling.js +251 -0
- package/dist/kimaki-digital-twin.e2e.test.js +165 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +160 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +253 -0
- package/dist/message-formatting.js +433 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode-plugin-loading.e2e.test.js +91 -0
- package/dist/opencode-plugin.js +536 -0
- package/dist/opencode-plugin.test.js +98 -0
- package/dist/opencode.js +409 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/runtime-mode.js +51 -0
- package/dist/runtime-mode.test.js +115 -0
- package/dist/sentry.js +127 -0
- package/dist/session-handler/state.js +151 -0
- package/dist/session-handler.js +1874 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/startup-service.js +153 -0
- package/dist/system-message.js +499 -0
- package/dist/task-runner.js +282 -0
- package/dist/task-schedule.js +191 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +781 -0
- package/dist/tools.js +359 -0
- package/dist/unnest-code-blocks.js +136 -0
- package/dist/unnest-code-blocks.test.js +641 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +109 -0
- package/dist/voice-handler.js +606 -0
- package/dist/voice.js +304 -0
- package/dist/voice.test.js +187 -0
- package/dist/wait-session.js +94 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +727 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +82 -0
- package/schema.prisma +246 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +129 -0
- package/skills/errore/SKILL.md +589 -0
- package/skills/goke/.prettierrc +5 -0
- package/skills/goke/CHANGELOG.md +40 -0
- package/skills/goke/LICENSE +21 -0
- package/skills/goke/README.md +666 -0
- package/skills/goke/SKILL.md +458 -0
- package/skills/goke/package.json +43 -0
- package/skills/goke/src/__test__/coerce.test.ts +411 -0
- package/skills/goke/src/__test__/index.test.ts +1798 -0
- package/skills/goke/src/__test__/types.test-d.ts +111 -0
- package/skills/goke/src/coerce.ts +547 -0
- package/skills/goke/src/goke.ts +1362 -0
- package/skills/goke/src/index.ts +16 -0
- package/skills/goke/src/mri.ts +164 -0
- package/skills/goke/tsconfig.json +15 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/playwriter/SKILL.md +31 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/zustand-centralized-state/SKILL.md +582 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +41 -0
- package/src/__snapshots__/first-session-no-info.md +17 -0
- package/src/__snapshots__/first-session-with-info.md +23 -0
- package/src/__snapshots__/session-1.md +17 -0
- package/src/__snapshots__/session-2.md +5871 -0
- package/src/__snapshots__/session-3.md +17 -0
- package/src/__snapshots__/session-with-tools.md +5871 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +282 -0
- package/src/ai-tool.ts +39 -0
- package/src/bin.ts +108 -0
- package/src/bot-token.test.ts +171 -0
- package/src/bot-token.ts +159 -0
- package/src/channel-management.ts +172 -0
- package/src/cli-parsing.test.ts +132 -0
- package/src/cli.ts +3605 -0
- package/src/commands/abort.ts +112 -0
- package/src/commands/action-buttons.ts +376 -0
- package/src/commands/add-project.ts +152 -0
- package/src/commands/agent.ts +404 -0
- package/src/commands/ask-question.ts +330 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +179 -0
- package/src/commands/diff.ts +165 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +320 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +634 -0
- package/src/commands/mention-mode.ts +77 -0
- package/src/commands/merge-worktree.ts +177 -0
- package/src/commands/model.ts +961 -0
- package/src/commands/permissions.ts +261 -0
- package/src/commands/queue.ts +296 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +242 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +250 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +221 -0
- package/src/commands/unset-model.ts +189 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +193 -0
- package/src/commands/verbosity.ts +88 -0
- package/src/commands/worktree-settings.ts +79 -0
- package/src/commands/worktree.ts +431 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +148 -0
- package/src/database.ts +1530 -0
- package/src/db.test.ts +60 -0
- package/src/db.ts +190 -0
- package/src/discord-api.ts +35 -0
- package/src/discord-bot.ts +1316 -0
- package/src/discord-utils.test.ts +132 -0
- package/src/discord-utils.ts +767 -0
- package/src/errors.ts +213 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/format-tables.test.ts +223 -0
- package/src/format-tables.ts +145 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +176 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +109 -0
- package/src/generated/client.ts +131 -0
- package/src/generated/commonInputTypes.ts +512 -0
- package/src/generated/enums.ts +46 -0
- package/src/generated/internal/class.ts +362 -0
- package/src/generated/internal/prismaNamespace.ts +2251 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1577 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +2104 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1224 -0
- package/src/generated/models/channel_worktrees.ts +1308 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1599 -0
- package/src/generated/models/thread_worktrees.ts +1352 -0
- package/src/generated/models.ts +29 -0
- package/src/heap-monitor.ts +121 -0
- package/src/hrana-server.test.ts +428 -0
- package/src/hrana-server.ts +547 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +461 -0
- package/src/ipc-polling.ts +325 -0
- package/src/kimaki-digital-twin.e2e.test.ts +201 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +203 -0
- package/src/markdown.test.ts +360 -0
- package/src/markdown.ts +410 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +549 -0
- package/src/openai-realtime.ts +362 -0
- package/src/opencode-plugin-loading.e2e.test.ts +112 -0
- package/src/opencode-plugin.test.ts +108 -0
- package/src/opencode-plugin.ts +652 -0
- package/src/opencode.ts +554 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/schema.sql +158 -0
- package/src/sentry.ts +137 -0
- package/src/session-handler/state.ts +232 -0
- package/src/session-handler.ts +2668 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/startup-service.ts +200 -0
- package/src/system-message.ts +568 -0
- package/src/task-runner.ts +425 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +287 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +997 -0
- package/src/tools.ts +432 -0
- package/src/unnest-code-blocks.test.ts +679 -0
- package/src/unnest-code-blocks.ts +168 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +145 -0
- package/src/voice-handler.ts +852 -0
- package/src/voice.test.ts +219 -0
- package/src/voice.ts +444 -0
- package/src/wait-session.ts +147 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-utils.ts +988 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
// Core Discord bot module that handles message events and bot lifecycle.
|
|
2
|
+
// Bridges Discord messages to OpenCode sessions, manages voice connections,
|
|
3
|
+
// and orchestrates the main event loop for the Kimaki bot.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
initDatabase,
|
|
7
|
+
closeDatabase,
|
|
8
|
+
getThreadWorktree,
|
|
9
|
+
createPendingWorktree,
|
|
10
|
+
setWorktreeReady,
|
|
11
|
+
setWorktreeError,
|
|
12
|
+
getChannelWorktreesEnabled,
|
|
13
|
+
getChannelMentionMode,
|
|
14
|
+
getChannelDirectory,
|
|
15
|
+
getThreadSession,
|
|
16
|
+
setThreadSession,
|
|
17
|
+
getPrisma,
|
|
18
|
+
cancelAllPendingIpcRequests,
|
|
19
|
+
} from './database.js'
|
|
20
|
+
import {
|
|
21
|
+
initializeOpencodeForDirectory,
|
|
22
|
+
getOpencodeServers,
|
|
23
|
+
} from './opencode.js'
|
|
24
|
+
import { formatWorktreeName } from './commands/worktree.js'
|
|
25
|
+
import { WORKTREE_PREFIX } from './commands/merge-worktree.js'
|
|
26
|
+
import { createWorktreeWithSubmodules } from './worktree-utils.js'
|
|
27
|
+
import {
|
|
28
|
+
escapeBackticksInCodeBlocks,
|
|
29
|
+
splitMarkdownForDiscord,
|
|
30
|
+
sendThreadMessage,
|
|
31
|
+
SILENT_MESSAGE_FLAGS,
|
|
32
|
+
reactToThread,
|
|
33
|
+
stripMentions,
|
|
34
|
+
hasKimakiBotPermission,
|
|
35
|
+
hasNoKimakiRole,
|
|
36
|
+
isGuildAllowed,
|
|
37
|
+
} from './discord-utils.js'
|
|
38
|
+
import {
|
|
39
|
+
getOpencodeSystemMessage,
|
|
40
|
+
type ThreadStartMarker,
|
|
41
|
+
} from './system-message.js'
|
|
42
|
+
import yaml from 'js-yaml'
|
|
43
|
+
import {
|
|
44
|
+
getFileAttachments,
|
|
45
|
+
getTextAttachments,
|
|
46
|
+
resolveMentions,
|
|
47
|
+
} from './message-formatting.js'
|
|
48
|
+
import {
|
|
49
|
+
ensureKimakiCategory,
|
|
50
|
+
ensureKimakiAudioCategory,
|
|
51
|
+
createProjectChannels,
|
|
52
|
+
getChannelsWithDescriptions,
|
|
53
|
+
type ChannelWithTags,
|
|
54
|
+
} from './channel-management.js'
|
|
55
|
+
import {
|
|
56
|
+
voiceConnections,
|
|
57
|
+
cleanupVoiceConnection,
|
|
58
|
+
processVoiceAttachment,
|
|
59
|
+
registerVoiceStateHandler,
|
|
60
|
+
} from './voice-handler.js'
|
|
61
|
+
import { getCompactSessionContext, getLastSessionId } from './markdown.js'
|
|
62
|
+
import {
|
|
63
|
+
handleOpencodeSession,
|
|
64
|
+
signalThreadInterrupt,
|
|
65
|
+
queueOrSendMessage,
|
|
66
|
+
abortControllers,
|
|
67
|
+
type SessionStartSourceContext,
|
|
68
|
+
} from './session-handler.js'
|
|
69
|
+
import { runShellCommand } from './commands/run-command.js'
|
|
70
|
+
import { registerInteractionHandler } from './interaction-handler.js'
|
|
71
|
+
import { stopHranaServer } from './hrana-server.js'
|
|
72
|
+
import { notifyError } from './sentry.js'
|
|
73
|
+
|
|
74
|
+
export {
|
|
75
|
+
initDatabase,
|
|
76
|
+
closeDatabase,
|
|
77
|
+
getChannelDirectory,
|
|
78
|
+
getPrisma,
|
|
79
|
+
} from './database.js'
|
|
80
|
+
export { initializeOpencodeForDirectory } from './opencode.js'
|
|
81
|
+
export {
|
|
82
|
+
escapeBackticksInCodeBlocks,
|
|
83
|
+
splitMarkdownForDiscord,
|
|
84
|
+
} from './discord-utils.js'
|
|
85
|
+
export { getOpencodeSystemMessage } from './system-message.js'
|
|
86
|
+
export {
|
|
87
|
+
ensureKimakiCategory,
|
|
88
|
+
ensureKimakiAudioCategory,
|
|
89
|
+
createProjectChannels,
|
|
90
|
+
getChannelsWithDescriptions,
|
|
91
|
+
} from './channel-management.js'
|
|
92
|
+
export type { ChannelWithTags } from './channel-management.js'
|
|
93
|
+
|
|
94
|
+
import {
|
|
95
|
+
ChannelType,
|
|
96
|
+
Client,
|
|
97
|
+
Events,
|
|
98
|
+
GatewayIntentBits,
|
|
99
|
+
Partials,
|
|
100
|
+
ThreadAutoArchiveDuration,
|
|
101
|
+
type Message,
|
|
102
|
+
type TextChannel,
|
|
103
|
+
type ThreadChannel,
|
|
104
|
+
} from 'discord.js'
|
|
105
|
+
import fs from 'node:fs'
|
|
106
|
+
import * as errore from 'errore'
|
|
107
|
+
import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js'
|
|
108
|
+
import { writeHeapSnapshot, startHeapMonitor } from './heap-monitor.js'
|
|
109
|
+
import { startTaskRunner } from './task-runner.js'
|
|
110
|
+
import { getDiscordApiBaseUrl } from './discord-api.js'
|
|
111
|
+
import { setGlobalDispatcher, Agent } from 'undici'
|
|
112
|
+
|
|
113
|
+
// Increase connection pool to prevent deadlock when multiple sessions have open SSE streams.
|
|
114
|
+
// Each session's event.subscribe() holds a connection; without enough connections,
|
|
115
|
+
// regular HTTP requests (question.reply, session.prompt) get blocked → deadlock.
|
|
116
|
+
setGlobalDispatcher(
|
|
117
|
+
new Agent({ headersTimeout: 0, bodyTimeout: 0, connections: 500 }),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const discordLogger = createLogger(LogPrefix.DISCORD)
|
|
121
|
+
const voiceLogger = createLogger(LogPrefix.VOICE)
|
|
122
|
+
|
|
123
|
+
// Per-thread serial queue so messages (voice + text) in the same thread are
|
|
124
|
+
// processed one at a time in arrival order. Without this, a slow voice
|
|
125
|
+
// transcription can finish after a fast text message and abort its session.
|
|
126
|
+
const threadMessageQueue = new Map<string, Promise<void>>()
|
|
127
|
+
|
|
128
|
+
function parseEmbedFooterMarker<T extends Record<string, unknown>>({
|
|
129
|
+
footer,
|
|
130
|
+
}: {
|
|
131
|
+
footer: string | undefined
|
|
132
|
+
}): T | undefined {
|
|
133
|
+
if (!footer) {
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const parsed = yaml.load(footer)
|
|
138
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
return parsed as T
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseSessionStartSourceFromMarker(
|
|
148
|
+
marker: ThreadStartMarker | undefined,
|
|
149
|
+
): SessionStartSourceContext | undefined {
|
|
150
|
+
if (!marker?.scheduledKind) {
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
if (marker.scheduledKind !== 'at' && marker.scheduledKind !== 'cron') {
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
typeof marker.scheduledTaskId !== 'number' ||
|
|
158
|
+
!Number.isInteger(marker.scheduledTaskId) ||
|
|
159
|
+
marker.scheduledTaskId < 1
|
|
160
|
+
) {
|
|
161
|
+
return { scheduleKind: marker.scheduledKind }
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
scheduleKind: marker.scheduledKind,
|
|
165
|
+
scheduledTaskId: marker.scheduledTaskId,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type StartOptions = {
|
|
170
|
+
token: string
|
|
171
|
+
appId?: string
|
|
172
|
+
/** When true, all new sessions from channel messages create git worktrees */
|
|
173
|
+
useWorktrees?: boolean
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function createDiscordClient() {
|
|
177
|
+
const apiBaseUrl = getDiscordApiBaseUrl()
|
|
178
|
+
return new Client({
|
|
179
|
+
intents: [
|
|
180
|
+
GatewayIntentBits.Guilds,
|
|
181
|
+
GatewayIntentBits.GuildMessages,
|
|
182
|
+
GatewayIntentBits.MessageContent,
|
|
183
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
184
|
+
],
|
|
185
|
+
partials: [
|
|
186
|
+
Partials.Channel,
|
|
187
|
+
Partials.Message,
|
|
188
|
+
Partials.User,
|
|
189
|
+
Partials.ThreadMember,
|
|
190
|
+
],
|
|
191
|
+
rest: {
|
|
192
|
+
api: apiBaseUrl,
|
|
193
|
+
version: '10',
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function startDiscordBot({
|
|
199
|
+
token,
|
|
200
|
+
appId,
|
|
201
|
+
discordClient,
|
|
202
|
+
useWorktrees,
|
|
203
|
+
}: StartOptions & { discordClient?: Client }) {
|
|
204
|
+
if (!discordClient) {
|
|
205
|
+
discordClient = await createDiscordClient()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let currentAppId: string | undefined = appId
|
|
209
|
+
|
|
210
|
+
const setupHandlers = async (c: Client<true>) => {
|
|
211
|
+
discordLogger.log(`Discord bot logged in as ${c.user.tag}`)
|
|
212
|
+
discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`)
|
|
213
|
+
discordLogger.log(`Bot user ID: ${c.user.id}`)
|
|
214
|
+
|
|
215
|
+
if (!currentAppId) {
|
|
216
|
+
await c.application?.fetch()
|
|
217
|
+
currentAppId = c.application?.id
|
|
218
|
+
|
|
219
|
+
if (!currentAppId) {
|
|
220
|
+
discordLogger.error('Could not get application ID')
|
|
221
|
+
throw new Error('Failed to get bot application ID')
|
|
222
|
+
}
|
|
223
|
+
discordLogger.log(`Bot Application ID (fetched): ${currentAppId}`)
|
|
224
|
+
} else {
|
|
225
|
+
discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
voiceLogger.log(
|
|
229
|
+
`[READY] Bot is ready and will only respond to channels with app ID: ${currentAppId}`,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
registerInteractionHandler({ discordClient: c, appId: currentAppId })
|
|
233
|
+
registerVoiceStateHandler({ discordClient: c, appId: currentAppId })
|
|
234
|
+
|
|
235
|
+
// Channel logging is informational only; do it in background so startup stays responsive.
|
|
236
|
+
void (async () => {
|
|
237
|
+
for (const guild of c.guilds.cache.values()) {
|
|
238
|
+
discordLogger.log(`${guild.name} (${guild.id})`)
|
|
239
|
+
|
|
240
|
+
const channels = await getChannelsWithDescriptions(guild)
|
|
241
|
+
const kimakiChannels = channels.filter(
|
|
242
|
+
(ch) =>
|
|
243
|
+
ch.kimakiDirectory &&
|
|
244
|
+
(!ch.kimakiApp || ch.kimakiApp === currentAppId),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if (kimakiChannels.length > 0) {
|
|
248
|
+
discordLogger.log(
|
|
249
|
+
` Found ${kimakiChannels.length} channel(s) for this bot:`,
|
|
250
|
+
)
|
|
251
|
+
for (const channel of kimakiChannels) {
|
|
252
|
+
discordLogger.log(
|
|
253
|
+
` - #${channel.name}: ${channel.kimakiDirectory}`,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
discordLogger.log(' No channels for this bot')
|
|
260
|
+
}
|
|
261
|
+
})().catch((error) => {
|
|
262
|
+
discordLogger.warn(
|
|
263
|
+
`Background guild channel scan failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// If client is already ready (was logged in before being passed to us),
|
|
269
|
+
// run setup immediately. Otherwise wait for the ClientReady event.
|
|
270
|
+
if (discordClient.isReady()) {
|
|
271
|
+
await setupHandlers(discordClient)
|
|
272
|
+
} else {
|
|
273
|
+
discordClient.once(Events.ClientReady, setupHandlers)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
discordClient.on(Events.MessageCreate, async (message: Message) => {
|
|
277
|
+
try {
|
|
278
|
+
if (!isGuildAllowed({ guildId: message.guildId })) {
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
const isSelfBotMessage = Boolean(
|
|
282
|
+
discordClient.user && message.author?.id === discordClient.user.id,
|
|
283
|
+
)
|
|
284
|
+
const promptMarker = parseEmbedFooterMarker<ThreadStartMarker>({
|
|
285
|
+
footer: message.embeds[0]?.footer?.text,
|
|
286
|
+
})
|
|
287
|
+
const isCliInjectedPrompt = Boolean(
|
|
288
|
+
isSelfBotMessage && promptMarker?.cliThreadPrompt,
|
|
289
|
+
)
|
|
290
|
+
const sessionStartSource = isCliInjectedPrompt
|
|
291
|
+
? parseSessionStartSourceFromMarker(promptMarker)
|
|
292
|
+
: undefined
|
|
293
|
+
const cliInjectedUsername = isCliInjectedPrompt
|
|
294
|
+
? promptMarker?.username || 'kimaki-cli'
|
|
295
|
+
: undefined
|
|
296
|
+
const cliInjectedUserId = isCliInjectedPrompt
|
|
297
|
+
? promptMarker?.userId
|
|
298
|
+
: undefined
|
|
299
|
+
const cliInjectedAgent = isCliInjectedPrompt
|
|
300
|
+
? promptMarker?.agent
|
|
301
|
+
: undefined
|
|
302
|
+
const cliInjectedModel = isCliInjectedPrompt
|
|
303
|
+
? promptMarker?.model
|
|
304
|
+
: undefined
|
|
305
|
+
|
|
306
|
+
// Always ignore our own messages (unless CLI-injected prompt above).
|
|
307
|
+
// Without this, assigning the Kimaki role to the bot itself would loop.
|
|
308
|
+
if (isSelfBotMessage && !isCliInjectedPrompt) {
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Allow bot messages through if the bot has the "Kimaki" role assigned.
|
|
313
|
+
// This enables multi-agent orchestration where other bots (e.g. an
|
|
314
|
+
// orchestrator) can @mention Kimaki and trigger sessions like a human.
|
|
315
|
+
if (message.author?.bot) {
|
|
316
|
+
if (!hasKimakiBotPermission(message.member)) {
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Ignore messages that start with a mention of another user (not the bot).
|
|
322
|
+
// These are likely users talking to each other, not the bot.
|
|
323
|
+
const leadingMentionMatch = message.content?.match(/^<@!?(\d+)>/)
|
|
324
|
+
if (leadingMentionMatch) {
|
|
325
|
+
const mentionedUserId = leadingMentionMatch[1]
|
|
326
|
+
if (mentionedUserId !== discordClient.user?.id) {
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (message.partial) {
|
|
332
|
+
discordLogger.log(`Fetching partial message ${message.id}`)
|
|
333
|
+
const fetched = await errore.tryAsync({
|
|
334
|
+
try: () => message.fetch(),
|
|
335
|
+
catch: (e) => e as Error,
|
|
336
|
+
})
|
|
337
|
+
if (fetched instanceof Error) {
|
|
338
|
+
discordLogger.log(
|
|
339
|
+
`Failed to fetch partial message ${message.id}:`,
|
|
340
|
+
fetched.message,
|
|
341
|
+
)
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Check mention mode BEFORE permission check for text channels.
|
|
347
|
+
// When mention mode is enabled, users without Kimaki role can message
|
|
348
|
+
// without getting a permission error - we just silently ignore.
|
|
349
|
+
const channel = message.channel
|
|
350
|
+
if (channel.type === ChannelType.GuildText && !isCliInjectedPrompt) {
|
|
351
|
+
const textChannel = channel as TextChannel
|
|
352
|
+
const mentionModeEnabled = await getChannelMentionMode(textChannel.id)
|
|
353
|
+
if (mentionModeEnabled) {
|
|
354
|
+
const botMentioned =
|
|
355
|
+
discordClient.user && message.mentions.has(discordClient.user.id)
|
|
356
|
+
const isShellCommand = message.content?.startsWith('!')
|
|
357
|
+
if (!botMentioned && !isShellCommand) {
|
|
358
|
+
voiceLogger.log(`[IGNORED] Mention mode enabled, bot not mentioned`)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!isCliInjectedPrompt && message.guild && message.member) {
|
|
365
|
+
if (hasNoKimakiRole(message.member)) {
|
|
366
|
+
await message.reply({
|
|
367
|
+
content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
|
|
368
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
369
|
+
})
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!hasKimakiBotPermission(message.member)) {
|
|
374
|
+
await message.reply({
|
|
375
|
+
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
376
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
377
|
+
})
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const isThread = [
|
|
383
|
+
ChannelType.PublicThread,
|
|
384
|
+
ChannelType.PrivateThread,
|
|
385
|
+
ChannelType.AnnouncementThread,
|
|
386
|
+
].includes(channel.type)
|
|
387
|
+
|
|
388
|
+
if (isThread) {
|
|
389
|
+
const thread = channel as ThreadChannel
|
|
390
|
+
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`)
|
|
391
|
+
|
|
392
|
+
const parent = thread.parent as TextChannel | null
|
|
393
|
+
let projectDirectory: string | undefined
|
|
394
|
+
let channelAppId: string | undefined
|
|
395
|
+
|
|
396
|
+
if (parent) {
|
|
397
|
+
const channelConfig = await getChannelDirectory(parent.id)
|
|
398
|
+
if (channelConfig) {
|
|
399
|
+
projectDirectory = channelConfig.directory
|
|
400
|
+
channelAppId = channelConfig.appId || undefined
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Check if this thread is a worktree thread
|
|
405
|
+
const worktreeInfo = await getThreadWorktree(thread.id)
|
|
406
|
+
if (worktreeInfo) {
|
|
407
|
+
if (worktreeInfo.status === 'pending') {
|
|
408
|
+
await message.reply({
|
|
409
|
+
content: '⏳ Worktree is still being created. Please wait...',
|
|
410
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
411
|
+
})
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
if (worktreeInfo.status === 'error') {
|
|
415
|
+
await message.reply({
|
|
416
|
+
content: `❌ Worktree creation failed: ${(worktreeInfo.error_message || '').slice(0, 1900)}`,
|
|
417
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
418
|
+
})
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
// Use original project directory for OpenCode server (session lives there)
|
|
422
|
+
// The worktree directory is passed via query.directory in prompt/command calls
|
|
423
|
+
if (worktreeInfo.project_directory) {
|
|
424
|
+
projectDirectory = worktreeInfo.project_directory
|
|
425
|
+
discordLogger.log(
|
|
426
|
+
`Using project directory: ${projectDirectory} (worktree: ${worktreeInfo.worktree_directory})`,
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
432
|
+
voiceLogger.log(
|
|
433
|
+
`[IGNORED] Thread belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
|
|
434
|
+
)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (projectDirectory && !fs.existsSync(projectDirectory)) {
|
|
439
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`)
|
|
440
|
+
await message.reply({
|
|
441
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
|
|
442
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
443
|
+
})
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ! prefix runs a shell command instead of starting/continuing a session
|
|
448
|
+
// Use worktree directory if available, so commands run in the worktree cwd
|
|
449
|
+
if (message.content?.startsWith('!') && projectDirectory) {
|
|
450
|
+
const shellCmd = message.content.slice(1).trim()
|
|
451
|
+
if (shellCmd) {
|
|
452
|
+
const shellDir =
|
|
453
|
+
worktreeInfo?.status === 'ready' &&
|
|
454
|
+
worktreeInfo.worktree_directory
|
|
455
|
+
? worktreeInfo.worktree_directory
|
|
456
|
+
: projectDirectory
|
|
457
|
+
const loadingReply = await message.reply({
|
|
458
|
+
content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
|
|
459
|
+
})
|
|
460
|
+
const result = await runShellCommand({
|
|
461
|
+
command: shellCmd,
|
|
462
|
+
directory: shellDir,
|
|
463
|
+
})
|
|
464
|
+
await loadingReply.edit({ content: result })
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Chain onto per-thread queue so messages (voice transcription + text)
|
|
470
|
+
// are processed in Discord arrival order, not completion order.
|
|
471
|
+
const hasVoiceAttachment = message.attachments.some((a) => {
|
|
472
|
+
return a.contentType?.startsWith('audio/')
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
const prev = threadMessageQueue.get(thread.id)
|
|
476
|
+
|
|
477
|
+
// Snapshot active request state NOW, before prev task finishes.
|
|
478
|
+
// Voice messages skip the eager interrupt so the session stays alive during
|
|
479
|
+
// transcription. But processThreadMessage is serialized behind prev, so by
|
|
480
|
+
// the time it runs the prev task may have finished and the controller is gone.
|
|
481
|
+
// This snapshot lets queueOrSendMessage know there WAS an active request
|
|
482
|
+
// when the voice message arrived, so it should queue even if the controller
|
|
483
|
+
// is no longer active.
|
|
484
|
+
// Conservative: if prev exists, something is actively being processed, so
|
|
485
|
+
// we treat it as having an active request (avoids race where the async
|
|
486
|
+
// getThreadSession call lets the prev task finish first).
|
|
487
|
+
const hadActiveRequestOnArrival: boolean = await (async () => {
|
|
488
|
+
if (!hasVoiceAttachment) {
|
|
489
|
+
return false
|
|
490
|
+
}
|
|
491
|
+
if (prev) {
|
|
492
|
+
return true
|
|
493
|
+
}
|
|
494
|
+
const sid = await getThreadSession(thread.id)
|
|
495
|
+
if (!sid) {
|
|
496
|
+
return false
|
|
497
|
+
}
|
|
498
|
+
const controller = abortControllers.get(sid)
|
|
499
|
+
return Boolean(controller && !controller.signal.aborted)
|
|
500
|
+
})()
|
|
501
|
+
if (prev && !hasVoiceAttachment) {
|
|
502
|
+
// Another message is being processed — abort it immediately so this
|
|
503
|
+
// queued message can start as soon as possible.
|
|
504
|
+
// Voice messages are excluded: they need transcription first to detect
|
|
505
|
+
// "queue this message" intent. Interrupting before transcription would
|
|
506
|
+
// abort the running session, making queueOrSendMessage see no active
|
|
507
|
+
// request and send immediately instead of queueing.
|
|
508
|
+
const sdkDirectory =
|
|
509
|
+
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
510
|
+
? worktreeInfo.worktree_directory
|
|
511
|
+
: projectDirectory
|
|
512
|
+
signalThreadInterrupt({
|
|
513
|
+
threadId: thread.id,
|
|
514
|
+
serverDirectory: projectDirectory,
|
|
515
|
+
sdkDirectory,
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
const task = (prev || Promise.resolve()).then(
|
|
519
|
+
() => { return processThreadMessage() },
|
|
520
|
+
() => { return processThreadMessage() },
|
|
521
|
+
)
|
|
522
|
+
threadMessageQueue.set(thread.id, task)
|
|
523
|
+
void task.finally(() => {
|
|
524
|
+
if (threadMessageQueue.get(thread.id) === task) {
|
|
525
|
+
threadMessageQueue.delete(thread.id)
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
await task
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
async function processThreadMessage() {
|
|
532
|
+
const sessionId = await getThreadSession(thread.id)
|
|
533
|
+
|
|
534
|
+
// No existing session - start a new one (e.g., replying to a notification thread)
|
|
535
|
+
if (!sessionId) {
|
|
536
|
+
discordLogger.log(
|
|
537
|
+
`No session for thread ${thread.id}, starting new session`,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if (!projectDirectory) {
|
|
541
|
+
discordLogger.log(
|
|
542
|
+
`Cannot start session: no project directory for thread ${thread.id}`,
|
|
543
|
+
)
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let prompt = resolveMentions(message)
|
|
548
|
+
const voiceResult = await processVoiceAttachment({
|
|
549
|
+
message,
|
|
550
|
+
thread,
|
|
551
|
+
projectDirectory,
|
|
552
|
+
appId: currentAppId,
|
|
553
|
+
})
|
|
554
|
+
if (voiceResult) {
|
|
555
|
+
prompt = `Voice message transcription from Discord user:\n\n${voiceResult.transcription}`
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// If voice transcription failed and there's no text content, bail out
|
|
559
|
+
if (hasVoiceAttachment && !voiceResult && !prompt.trim()) {
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const starterMessage = await thread
|
|
564
|
+
.fetchStarterMessage()
|
|
565
|
+
.catch((error) => {
|
|
566
|
+
discordLogger.warn(
|
|
567
|
+
`[SESSION] Failed to fetch starter message for thread ${thread.id}:`,
|
|
568
|
+
error instanceof Error ? error.message : String(error),
|
|
569
|
+
)
|
|
570
|
+
return null
|
|
571
|
+
})
|
|
572
|
+
if (starterMessage && starterMessage.content !== message.content) {
|
|
573
|
+
const starterTextAttachments = await getTextAttachments(starterMessage)
|
|
574
|
+
const starterContent = resolveMentions(starterMessage)
|
|
575
|
+
const starterText = starterTextAttachments
|
|
576
|
+
? `${starterContent}\n\n${starterTextAttachments}`
|
|
577
|
+
: starterContent
|
|
578
|
+
if (starterText) {
|
|
579
|
+
prompt = `Context from thread:\n${starterText}\n\nUser request:\n${prompt}`
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
await handleOpencodeSession({
|
|
584
|
+
prompt,
|
|
585
|
+
thread,
|
|
586
|
+
projectDirectory,
|
|
587
|
+
channelId: parent?.id || '',
|
|
588
|
+
username:
|
|
589
|
+
cliInjectedUsername ||
|
|
590
|
+
message.member?.displayName ||
|
|
591
|
+
message.author.displayName,
|
|
592
|
+
userId: cliInjectedUserId || message.author.id,
|
|
593
|
+
appId: currentAppId,
|
|
594
|
+
sessionStartSource,
|
|
595
|
+
agent: cliInjectedAgent,
|
|
596
|
+
model: cliInjectedModel,
|
|
597
|
+
})
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
voiceLogger.log(
|
|
602
|
+
`[SESSION] Found session ${sessionId} for thread ${thread.id}`,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
let messageContent = resolveMentions(message)
|
|
606
|
+
if (isCliInjectedPrompt) {
|
|
607
|
+
messageContent = message.content || ''
|
|
608
|
+
}
|
|
609
|
+
let currentSessionContext: string | undefined
|
|
610
|
+
let lastSessionContext: string | undefined
|
|
611
|
+
|
|
612
|
+
if (projectDirectory) {
|
|
613
|
+
try {
|
|
614
|
+
const getClient = await initializeOpencodeForDirectory(
|
|
615
|
+
projectDirectory,
|
|
616
|
+
{ channelId: parent?.id },
|
|
617
|
+
)
|
|
618
|
+
if (getClient instanceof Error) {
|
|
619
|
+
voiceLogger.error(
|
|
620
|
+
`[SESSION] Failed to initialize OpenCode client:`,
|
|
621
|
+
getClient.message,
|
|
622
|
+
)
|
|
623
|
+
throw new Error(getClient.message)
|
|
624
|
+
}
|
|
625
|
+
const client = getClient()
|
|
626
|
+
|
|
627
|
+
// get current session context (without system prompt, it would be duplicated)
|
|
628
|
+
if (sessionId) {
|
|
629
|
+
const result = await getCompactSessionContext({
|
|
630
|
+
client,
|
|
631
|
+
sessionId: sessionId,
|
|
632
|
+
includeSystemPrompt: false,
|
|
633
|
+
maxMessages: 15,
|
|
634
|
+
})
|
|
635
|
+
if (errore.isOk(result)) {
|
|
636
|
+
currentSessionContext = result
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// get last session context (with system prompt for project context)
|
|
641
|
+
const lastSessionResult = await getLastSessionId({
|
|
642
|
+
client,
|
|
643
|
+
excludeSessionId: sessionId,
|
|
644
|
+
})
|
|
645
|
+
const lastSessionId = errore.unwrapOr(lastSessionResult, null)
|
|
646
|
+
if (lastSessionId) {
|
|
647
|
+
const result = await getCompactSessionContext({
|
|
648
|
+
client,
|
|
649
|
+
sessionId: lastSessionId,
|
|
650
|
+
includeSystemPrompt: true,
|
|
651
|
+
maxMessages: 10,
|
|
652
|
+
})
|
|
653
|
+
if (errore.isOk(result)) {
|
|
654
|
+
lastSessionContext = result
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
} catch (e) {
|
|
658
|
+
voiceLogger.error(`Could not get session context:`, e)
|
|
659
|
+
void notifyError(e, 'Failed to get session context')
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const voiceResult = await processVoiceAttachment({
|
|
664
|
+
message,
|
|
665
|
+
thread,
|
|
666
|
+
projectDirectory,
|
|
667
|
+
appId: currentAppId,
|
|
668
|
+
currentSessionContext,
|
|
669
|
+
lastSessionContext,
|
|
670
|
+
})
|
|
671
|
+
if (voiceResult) {
|
|
672
|
+
messageContent = `Voice message transcription from Discord user:\n\n${voiceResult.transcription}`
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// If voice transcription failed (returned null) and there's no text content,
|
|
676
|
+
// bail out — don't fire deferred interrupt or send an empty prompt.
|
|
677
|
+
if (hasVoiceAttachment && !voiceResult && !messageContent.trim()) {
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// If the transcription model detected "queue this message" intent,
|
|
682
|
+
// use queueOrSendMessage instead of sending immediately.
|
|
683
|
+
if (voiceResult?.queueMessage) {
|
|
684
|
+
const fileAttachments = await getFileAttachments(message)
|
|
685
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
686
|
+
const promptWithAttachments = textAttachmentsContent
|
|
687
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
688
|
+
: messageContent
|
|
689
|
+
const username =
|
|
690
|
+
cliInjectedUsername ||
|
|
691
|
+
message.member?.displayName ||
|
|
692
|
+
message.author.displayName
|
|
693
|
+
const result = await queueOrSendMessage({
|
|
694
|
+
thread,
|
|
695
|
+
prompt: promptWithAttachments,
|
|
696
|
+
userId: isCliInjectedPrompt
|
|
697
|
+
? cliInjectedUserId || message.author.id
|
|
698
|
+
: message.author.id,
|
|
699
|
+
username,
|
|
700
|
+
appId: currentAppId,
|
|
701
|
+
images: fileAttachments,
|
|
702
|
+
forceQueue: hadActiveRequestOnArrival,
|
|
703
|
+
})
|
|
704
|
+
if (result.action === 'queued') {
|
|
705
|
+
await sendThreadMessage(
|
|
706
|
+
thread,
|
|
707
|
+
`Queued (position: ${result.position}). Will be sent after current response.`,
|
|
708
|
+
)
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
if (result.action === 'sent') {
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
// no-session / no-directory: fall through to normal handleOpencodeSession flow
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// For voice messages without queue intent, we deferred the interrupt
|
|
718
|
+
// until after transcription (to preserve active-request state for queue
|
|
719
|
+
// detection). Now that we know it's not a queue message, signal the
|
|
720
|
+
// interrupt so the running session aborts before the new prompt is sent.
|
|
721
|
+
if (hasVoiceAttachment && !voiceResult?.queueMessage) {
|
|
722
|
+
const sdkDirectory =
|
|
723
|
+
worktreeInfo?.status === 'ready' && worktreeInfo.worktree_directory
|
|
724
|
+
? worktreeInfo.worktree_directory
|
|
725
|
+
: projectDirectory
|
|
726
|
+
signalThreadInterrupt({
|
|
727
|
+
threadId: thread.id,
|
|
728
|
+
serverDirectory: projectDirectory,
|
|
729
|
+
sdkDirectory,
|
|
730
|
+
})
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const fileAttachments = await getFileAttachments(message)
|
|
734
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
735
|
+
const promptWithAttachments = textAttachmentsContent
|
|
736
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
737
|
+
: messageContent
|
|
738
|
+
await handleOpencodeSession({
|
|
739
|
+
prompt: promptWithAttachments,
|
|
740
|
+
thread,
|
|
741
|
+
projectDirectory,
|
|
742
|
+
originalMessage: message,
|
|
743
|
+
images: fileAttachments,
|
|
744
|
+
channelId: parent?.id,
|
|
745
|
+
username:
|
|
746
|
+
cliInjectedUsername ||
|
|
747
|
+
message.member?.displayName ||
|
|
748
|
+
message.author.displayName,
|
|
749
|
+
userId: isCliInjectedPrompt ? cliInjectedUserId : message.author.id,
|
|
750
|
+
appId: currentAppId,
|
|
751
|
+
sessionStartSource,
|
|
752
|
+
agent: cliInjectedAgent,
|
|
753
|
+
model: cliInjectedModel,
|
|
754
|
+
})
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (channel.type === ChannelType.GuildText) {
|
|
759
|
+
const textChannel = channel as TextChannel
|
|
760
|
+
voiceLogger.log(
|
|
761
|
+
`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
const channelConfig = await getChannelDirectory(textChannel.id)
|
|
765
|
+
|
|
766
|
+
if (!channelConfig) {
|
|
767
|
+
voiceLogger.log(
|
|
768
|
+
`[IGNORED] Channel #${textChannel.name} has no project directory configured`,
|
|
769
|
+
)
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const projectDirectory = channelConfig.directory
|
|
774
|
+
const channelAppId = channelConfig.appId || undefined
|
|
775
|
+
|
|
776
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
777
|
+
voiceLogger.log(
|
|
778
|
+
`[IGNORED] Channel belongs to different bot app (expected: ${currentAppId}, got: ${channelAppId})`,
|
|
779
|
+
)
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Note: Mention mode is checked early in the handler (before permission check)
|
|
784
|
+
// to avoid sending permission errors to users who just didn't @mention the bot.
|
|
785
|
+
|
|
786
|
+
discordLogger.log(`DIRECTORY: Found kimaki.directory: ${projectDirectory}`)
|
|
787
|
+
if (channelAppId) {
|
|
788
|
+
discordLogger.log(`APP: Channel app ID: ${channelAppId}`)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
792
|
+
discordLogger.error(`Directory does not exist: ${projectDirectory}`)
|
|
793
|
+
await message.reply({
|
|
794
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
|
|
795
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
796
|
+
})
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ! prefix runs a shell command instead of starting a session
|
|
801
|
+
if (message.content?.startsWith('!')) {
|
|
802
|
+
const shellCmd = message.content.slice(1).trim()
|
|
803
|
+
if (shellCmd) {
|
|
804
|
+
const loadingReply = await message.reply({
|
|
805
|
+
content: `Running \`${shellCmd.slice(0, 1900)}\`...`,
|
|
806
|
+
})
|
|
807
|
+
const result = await runShellCommand({
|
|
808
|
+
command: shellCmd,
|
|
809
|
+
directory: projectDirectory,
|
|
810
|
+
})
|
|
811
|
+
await loadingReply.edit({ content: result })
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const hasVoice = message.attachments.some((a) =>
|
|
817
|
+
a.contentType?.startsWith('audio/'),
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
const baseThreadName = hasVoice
|
|
821
|
+
? 'Voice Message'
|
|
822
|
+
: stripMentions(message.content || '')
|
|
823
|
+
.replace(/\s+/g, ' ')
|
|
824
|
+
.trim() || 'kimaki thread'
|
|
825
|
+
|
|
826
|
+
// Check if worktrees should be enabled (CLI flag OR channel setting)
|
|
827
|
+
const shouldUseWorktrees =
|
|
828
|
+
useWorktrees || (await getChannelWorktreesEnabled(textChannel.id))
|
|
829
|
+
|
|
830
|
+
// Add worktree prefix if worktrees are enabled
|
|
831
|
+
const threadName = shouldUseWorktrees
|
|
832
|
+
? `${WORKTREE_PREFIX}${baseThreadName}`
|
|
833
|
+
: baseThreadName
|
|
834
|
+
|
|
835
|
+
const thread = await message.startThread({
|
|
836
|
+
name: threadName.slice(0, 80),
|
|
837
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
838
|
+
reason: 'Start Claude session',
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
// Add user to thread so it appears in their sidebar
|
|
842
|
+
await thread.members.add(message.author.id)
|
|
843
|
+
|
|
844
|
+
discordLogger.log(`Created thread "${thread.name}" (${thread.id})`)
|
|
845
|
+
|
|
846
|
+
// Create worktree if worktrees are enabled (CLI flag OR channel setting)
|
|
847
|
+
let sessionDirectory = projectDirectory
|
|
848
|
+
if (shouldUseWorktrees) {
|
|
849
|
+
const worktreeName = formatWorktreeName(
|
|
850
|
+
hasVoice ? `voice-${Date.now()}` : threadName.slice(0, 50),
|
|
851
|
+
)
|
|
852
|
+
discordLogger.log(`[WORKTREE] Creating worktree: ${worktreeName}`)
|
|
853
|
+
|
|
854
|
+
// Store pending worktree immediately so bot knows about it
|
|
855
|
+
await createPendingWorktree({
|
|
856
|
+
threadId: thread.id,
|
|
857
|
+
worktreeName,
|
|
858
|
+
projectDirectory,
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
const worktreeResult = await createWorktreeWithSubmodules({
|
|
862
|
+
directory: projectDirectory,
|
|
863
|
+
name: worktreeName,
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
if (worktreeResult instanceof Error) {
|
|
867
|
+
const errMsg = worktreeResult.message
|
|
868
|
+
discordLogger.error(`[WORKTREE] Creation failed: ${errMsg}`)
|
|
869
|
+
await setWorktreeError({
|
|
870
|
+
threadId: thread.id,
|
|
871
|
+
errorMessage: errMsg,
|
|
872
|
+
})
|
|
873
|
+
await thread.send({
|
|
874
|
+
content: `⚠️ Failed to create worktree: ${errMsg}\nUsing main project directory instead.`,
|
|
875
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
876
|
+
})
|
|
877
|
+
} else {
|
|
878
|
+
await setWorktreeReady({
|
|
879
|
+
threadId: thread.id,
|
|
880
|
+
worktreeDirectory: worktreeResult.directory,
|
|
881
|
+
})
|
|
882
|
+
sessionDirectory = worktreeResult.directory
|
|
883
|
+
discordLogger.log(
|
|
884
|
+
`[WORKTREE] Created: ${worktreeResult.directory} (branch: ${worktreeResult.branch})`,
|
|
885
|
+
)
|
|
886
|
+
// React with tree emoji to mark as worktree thread
|
|
887
|
+
await reactToThread({
|
|
888
|
+
rest: discordClient.rest,
|
|
889
|
+
threadId: thread.id,
|
|
890
|
+
channelId: thread.parentId || undefined,
|
|
891
|
+
emoji: '🌳',
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
let messageContent = resolveMentions(message)
|
|
897
|
+
const voiceResult = await processVoiceAttachment({
|
|
898
|
+
message,
|
|
899
|
+
thread,
|
|
900
|
+
projectDirectory: sessionDirectory,
|
|
901
|
+
isNewThread: true,
|
|
902
|
+
appId: currentAppId,
|
|
903
|
+
})
|
|
904
|
+
if (voiceResult) {
|
|
905
|
+
messageContent = `Voice message transcription from Discord user:\n\n${voiceResult.transcription}`
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// If voice transcription failed and there's no text content, bail out
|
|
909
|
+
if (hasVoice && !voiceResult && !messageContent.trim()) {
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const fileAttachments = await getFileAttachments(message)
|
|
914
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
915
|
+
const promptWithAttachments = textAttachmentsContent
|
|
916
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
917
|
+
: messageContent
|
|
918
|
+
await handleOpencodeSession({
|
|
919
|
+
prompt: promptWithAttachments,
|
|
920
|
+
thread,
|
|
921
|
+
projectDirectory: sessionDirectory,
|
|
922
|
+
originalMessage: message,
|
|
923
|
+
images: fileAttachments,
|
|
924
|
+
channelId: textChannel.id,
|
|
925
|
+
username: message.member?.displayName || message.author.displayName,
|
|
926
|
+
userId: message.author.id,
|
|
927
|
+
appId: currentAppId,
|
|
928
|
+
})
|
|
929
|
+
} else {
|
|
930
|
+
discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
931
|
+
}
|
|
932
|
+
} catch (error) {
|
|
933
|
+
voiceLogger.error('Discord handler error:', error)
|
|
934
|
+
void notifyError(error, 'MessageCreate handler error')
|
|
935
|
+
try {
|
|
936
|
+
const errMsg = (
|
|
937
|
+
error instanceof Error ? error.message : String(error)
|
|
938
|
+
).slice(0, 1900)
|
|
939
|
+
await message.reply({
|
|
940
|
+
content: `Error: ${errMsg}`,
|
|
941
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
942
|
+
})
|
|
943
|
+
} catch (sendError) {
|
|
944
|
+
voiceLogger.error(
|
|
945
|
+
'Discord handler error (fallback):',
|
|
946
|
+
sendError instanceof Error ? sendError.message : String(sendError),
|
|
947
|
+
)
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
953
|
+
// Uses JSON embed marker to pass options (start, worktree name)
|
|
954
|
+
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
955
|
+
try {
|
|
956
|
+
if (!isGuildAllowed({ guildId: thread.guildId })) {
|
|
957
|
+
return
|
|
958
|
+
}
|
|
959
|
+
if (!newlyCreated) {
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Only handle threads in text channels
|
|
964
|
+
const parent = thread.parent as TextChannel | null
|
|
965
|
+
if (!parent || parent.type !== ChannelType.GuildText) {
|
|
966
|
+
return
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Get the starter message to check for auto-start marker
|
|
970
|
+
const starterMessage = await thread
|
|
971
|
+
.fetchStarterMessage()
|
|
972
|
+
.catch((error) => {
|
|
973
|
+
discordLogger.warn(
|
|
974
|
+
`[THREAD_CREATE] Failed to fetch starter message for thread ${thread.id}:`,
|
|
975
|
+
error instanceof Error ? error.message : String(error),
|
|
976
|
+
)
|
|
977
|
+
return null
|
|
978
|
+
})
|
|
979
|
+
if (!starterMessage) {
|
|
980
|
+
discordLogger.log(
|
|
981
|
+
`[THREAD_CREATE] Could not fetch starter message for thread ${thread.id}`,
|
|
982
|
+
)
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Parse JSON marker from embed footer
|
|
987
|
+
const embedFooter = starterMessage.embeds[0]?.footer?.text
|
|
988
|
+
if (!embedFooter) {
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const marker = parseEmbedFooterMarker<ThreadStartMarker>({
|
|
993
|
+
footer: embedFooter,
|
|
994
|
+
})
|
|
995
|
+
if (!marker) {
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (!marker.start) {
|
|
1000
|
+
return // Not an auto-start thread
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
discordLogger.log(
|
|
1004
|
+
`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`,
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
const textAttachmentsContent = await getTextAttachments(starterMessage)
|
|
1008
|
+
const messageText = resolveMentions(starterMessage).trim()
|
|
1009
|
+
const prompt = textAttachmentsContent
|
|
1010
|
+
? `${messageText}\n\n${textAttachmentsContent}`
|
|
1011
|
+
: messageText
|
|
1012
|
+
if (!prompt) {
|
|
1013
|
+
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`)
|
|
1014
|
+
return
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Get directory from database
|
|
1018
|
+
const channelConfig = await getChannelDirectory(parent.id)
|
|
1019
|
+
|
|
1020
|
+
if (!channelConfig) {
|
|
1021
|
+
discordLogger.log(
|
|
1022
|
+
`[BOT_SESSION] No project directory configured for parent channel`,
|
|
1023
|
+
)
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const projectDirectory = channelConfig.directory
|
|
1028
|
+
const channelAppId = channelConfig.appId || undefined
|
|
1029
|
+
|
|
1030
|
+
if (channelAppId && channelAppId !== currentAppId) {
|
|
1031
|
+
discordLogger.log(`[BOT_SESSION] Channel belongs to different bot app`)
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
1036
|
+
discordLogger.error(
|
|
1037
|
+
`[BOT_SESSION] Directory does not exist: ${projectDirectory}`,
|
|
1038
|
+
)
|
|
1039
|
+
await thread.send({
|
|
1040
|
+
content: `✗ Directory does not exist: ${JSON.stringify(projectDirectory).slice(0, 1900)}`,
|
|
1041
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
1042
|
+
})
|
|
1043
|
+
return
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Create worktree if requested
|
|
1047
|
+
const sessionDirectory: string = await (async () => {
|
|
1048
|
+
if (!marker.worktree) {
|
|
1049
|
+
return projectDirectory
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
discordLogger.log(`[BOT_SESSION] Creating worktree: ${marker.worktree}`)
|
|
1053
|
+
|
|
1054
|
+
const worktreeStatusMessage = await thread
|
|
1055
|
+
.send({
|
|
1056
|
+
content: `🌳 Creating worktree: ${marker.worktree}\n⏳ Setting up (this can take a bit)...`,
|
|
1057
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
1058
|
+
})
|
|
1059
|
+
.catch(() => {
|
|
1060
|
+
return null
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
await createPendingWorktree({
|
|
1064
|
+
threadId: thread.id,
|
|
1065
|
+
worktreeName: marker.worktree,
|
|
1066
|
+
projectDirectory,
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
const worktreeResult = await createWorktreeWithSubmodules({
|
|
1070
|
+
directory: projectDirectory,
|
|
1071
|
+
name: marker.worktree,
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
if (errore.isError(worktreeResult)) {
|
|
1075
|
+
discordLogger.error(
|
|
1076
|
+
`[BOT_SESSION] Worktree creation failed: ${worktreeResult.message}`,
|
|
1077
|
+
)
|
|
1078
|
+
await setWorktreeError({
|
|
1079
|
+
threadId: thread.id,
|
|
1080
|
+
errorMessage: worktreeResult.message,
|
|
1081
|
+
})
|
|
1082
|
+
await (worktreeStatusMessage?.edit({
|
|
1083
|
+
content: `⚠️ Failed to create worktree: ${worktreeResult.message}\nUsing main project directory instead.`,
|
|
1084
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
1085
|
+
}) ||
|
|
1086
|
+
thread.send({
|
|
1087
|
+
content: `⚠️ Failed to create worktree: ${worktreeResult.message}\nUsing main project directory instead.`,
|
|
1088
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
1089
|
+
}))
|
|
1090
|
+
return projectDirectory
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
await setWorktreeReady({
|
|
1094
|
+
threadId: thread.id,
|
|
1095
|
+
worktreeDirectory: worktreeResult.directory,
|
|
1096
|
+
})
|
|
1097
|
+
discordLogger.log(
|
|
1098
|
+
`[BOT_SESSION] Worktree created: ${worktreeResult.directory}`,
|
|
1099
|
+
)
|
|
1100
|
+
// React with tree emoji to mark as worktree thread
|
|
1101
|
+
await reactToThread({
|
|
1102
|
+
rest: discordClient.rest,
|
|
1103
|
+
threadId: thread.id,
|
|
1104
|
+
channelId: thread.parentId || undefined,
|
|
1105
|
+
emoji: '🌳',
|
|
1106
|
+
})
|
|
1107
|
+
await (worktreeStatusMessage?.edit({
|
|
1108
|
+
content: `🌳 **Worktree ready: ${marker.worktree}**\n📁 \`${worktreeResult.directory}\`\n🌿 Branch: \`${worktreeResult.branch}\``,
|
|
1109
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
1110
|
+
}) ||
|
|
1111
|
+
thread.send({
|
|
1112
|
+
content: `🌳 **Worktree ready: ${marker.worktree}**\n📁 \`${worktreeResult.directory}\`\n🌿 Branch: \`${worktreeResult.branch}\``,
|
|
1113
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
1114
|
+
}))
|
|
1115
|
+
return worktreeResult.directory
|
|
1116
|
+
})()
|
|
1117
|
+
|
|
1118
|
+
discordLogger.log(
|
|
1119
|
+
`[BOT_SESSION] Starting session for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}..."`,
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
const botThreadStartSource = parseSessionStartSourceFromMarker(marker)
|
|
1123
|
+
|
|
1124
|
+
await handleOpencodeSession({
|
|
1125
|
+
prompt,
|
|
1126
|
+
thread,
|
|
1127
|
+
projectDirectory: sessionDirectory,
|
|
1128
|
+
channelId: parent.id,
|
|
1129
|
+
appId: currentAppId,
|
|
1130
|
+
username: marker.username,
|
|
1131
|
+
userId: marker.userId,
|
|
1132
|
+
agent: marker.agent,
|
|
1133
|
+
model: marker.model,
|
|
1134
|
+
sessionStartSource: botThreadStartSource,
|
|
1135
|
+
})
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
voiceLogger.error(
|
|
1138
|
+
'[BOT_SESSION] Error handling bot-initiated thread:',
|
|
1139
|
+
error,
|
|
1140
|
+
)
|
|
1141
|
+
void notifyError(error, 'ThreadCreate handler error')
|
|
1142
|
+
try {
|
|
1143
|
+
const errMsg = (
|
|
1144
|
+
error instanceof Error ? error.message : String(error)
|
|
1145
|
+
).slice(0, 1900)
|
|
1146
|
+
await thread.send({
|
|
1147
|
+
content: `Error: ${errMsg}`,
|
|
1148
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
1149
|
+
})
|
|
1150
|
+
} catch (sendError) {
|
|
1151
|
+
voiceLogger.error(
|
|
1152
|
+
'[BOT_SESSION] Failed to send error message:',
|
|
1153
|
+
sendError instanceof Error ? sendError.message : String(sendError),
|
|
1154
|
+
)
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
await discordClient.login(token)
|
|
1160
|
+
|
|
1161
|
+
startHeapMonitor()
|
|
1162
|
+
const stopTaskRunner = startTaskRunner({ token })
|
|
1163
|
+
|
|
1164
|
+
const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
|
|
1165
|
+
discordLogger.log(`Received ${signal}, cleaning up...`)
|
|
1166
|
+
|
|
1167
|
+
if ((global as any).shuttingDown) {
|
|
1168
|
+
discordLogger.log('Already shutting down, ignoring duplicate signal')
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
;(global as any).shuttingDown = true
|
|
1172
|
+
|
|
1173
|
+
try {
|
|
1174
|
+
await stopTaskRunner()
|
|
1175
|
+
|
|
1176
|
+
// Cancel pending IPC requests so plugin tools don't hang
|
|
1177
|
+
await cancelAllPendingIpcRequests().catch((e) => {
|
|
1178
|
+
discordLogger.warn(
|
|
1179
|
+
'Failed to cancel pending IPC requests:',
|
|
1180
|
+
(e as Error).message,
|
|
1181
|
+
)
|
|
1182
|
+
})
|
|
1183
|
+
|
|
1184
|
+
const cleanupPromises: Promise<void>[] = []
|
|
1185
|
+
for (const [guildId] of voiceConnections) {
|
|
1186
|
+
voiceLogger.log(
|
|
1187
|
+
`[SHUTDOWN] Cleaning up voice connection for guild ${guildId}`,
|
|
1188
|
+
)
|
|
1189
|
+
cleanupPromises.push(cleanupVoiceConnection(guildId))
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (cleanupPromises.length > 0) {
|
|
1193
|
+
voiceLogger.log(
|
|
1194
|
+
`[SHUTDOWN] Waiting for ${cleanupPromises.length} voice connection(s) to clean up...`,
|
|
1195
|
+
)
|
|
1196
|
+
await Promise.allSettled(cleanupPromises)
|
|
1197
|
+
discordLogger.log(`All voice connections cleaned up`)
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
for (const [dir, server] of getOpencodeServers()) {
|
|
1201
|
+
if (!server.process.killed) {
|
|
1202
|
+
voiceLogger.log(
|
|
1203
|
+
`[SHUTDOWN] Stopping OpenCode server on port ${server.port} for ${dir}`,
|
|
1204
|
+
)
|
|
1205
|
+
server.process.kill('SIGTERM')
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
getOpencodeServers().clear()
|
|
1209
|
+
|
|
1210
|
+
discordLogger.log('Closing database...')
|
|
1211
|
+
await closeDatabase()
|
|
1212
|
+
|
|
1213
|
+
discordLogger.log('Stopping hrana server...')
|
|
1214
|
+
await stopHranaServer()
|
|
1215
|
+
|
|
1216
|
+
discordLogger.log('Destroying Discord client...')
|
|
1217
|
+
discordClient.destroy()
|
|
1218
|
+
|
|
1219
|
+
discordLogger.log('Cleanup complete.')
|
|
1220
|
+
if (!skipExit) {
|
|
1221
|
+
process.exit(0)
|
|
1222
|
+
}
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
|
|
1225
|
+
if (!skipExit) {
|
|
1226
|
+
process.exit(1)
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
process.on('SIGTERM', async () => {
|
|
1232
|
+
try {
|
|
1233
|
+
await handleShutdown('SIGTERM')
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
voiceLogger.error('[SIGTERM] Error during shutdown:', error)
|
|
1236
|
+
process.exit(1)
|
|
1237
|
+
}
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
process.on('SIGINT', async () => {
|
|
1241
|
+
try {
|
|
1242
|
+
await handleShutdown('SIGINT')
|
|
1243
|
+
} catch (error) {
|
|
1244
|
+
voiceLogger.error('[SIGINT] Error during shutdown:', error)
|
|
1245
|
+
process.exit(1)
|
|
1246
|
+
}
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
process.on('SIGUSR1', () => {
|
|
1250
|
+
discordLogger.log('Received SIGUSR1, writing heap snapshot...')
|
|
1251
|
+
try {
|
|
1252
|
+
writeHeapSnapshot()
|
|
1253
|
+
} catch (e) {
|
|
1254
|
+
discordLogger.error(
|
|
1255
|
+
'Failed to write heap snapshot:',
|
|
1256
|
+
e instanceof Error ? e.message : String(e),
|
|
1257
|
+
)
|
|
1258
|
+
}
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
process.on('SIGUSR2', async () => {
|
|
1262
|
+
discordLogger.log('Received SIGUSR2, restarting after cleanup...')
|
|
1263
|
+
try {
|
|
1264
|
+
await handleShutdown('SIGUSR2', { skipExit: true })
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
|
|
1267
|
+
}
|
|
1268
|
+
const { spawn } = await import('node:child_process')
|
|
1269
|
+
// Strip __KIMAKI_CHILD so the new process goes through the respawn wrapper in bin.js.
|
|
1270
|
+
// V8 heap flags are already in process.execArgv from the initial spawn, and bin.ts
|
|
1271
|
+
// will re-inject them if missing, so no need to add them here.
|
|
1272
|
+
const env = { ...process.env }
|
|
1273
|
+
delete env.__KIMAKI_CHILD
|
|
1274
|
+
spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
|
|
1275
|
+
stdio: 'inherit',
|
|
1276
|
+
detached: true,
|
|
1277
|
+
cwd: process.cwd(),
|
|
1278
|
+
env,
|
|
1279
|
+
}).unref()
|
|
1280
|
+
process.exit(0)
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
process.on('uncaughtException', (error) => {
|
|
1284
|
+
discordLogger.error('Uncaught exception:', formatErrorWithStack(error))
|
|
1285
|
+
notifyError(error, 'Uncaught exception in bot process')
|
|
1286
|
+
void handleShutdown('uncaughtException', { skipExit: true }).catch(
|
|
1287
|
+
(shutdownError) => {
|
|
1288
|
+
discordLogger.error(
|
|
1289
|
+
'[uncaughtException] shutdown failed:',
|
|
1290
|
+
formatErrorWithStack(shutdownError),
|
|
1291
|
+
)
|
|
1292
|
+
},
|
|
1293
|
+
)
|
|
1294
|
+
setTimeout(() => {
|
|
1295
|
+
process.exit(1)
|
|
1296
|
+
}, 250).unref()
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
1300
|
+
if ((global as any).shuttingDown) {
|
|
1301
|
+
discordLogger.log('Ignoring unhandled rejection during shutdown:', reason)
|
|
1302
|
+
return
|
|
1303
|
+
}
|
|
1304
|
+
discordLogger.error(
|
|
1305
|
+
'Unhandled rejection:',
|
|
1306
|
+
formatErrorWithStack(reason),
|
|
1307
|
+
'at promise:',
|
|
1308
|
+
promise,
|
|
1309
|
+
)
|
|
1310
|
+
const error =
|
|
1311
|
+
reason instanceof Error
|
|
1312
|
+
? reason
|
|
1313
|
+
: new Error(formatErrorWithStack(reason))
|
|
1314
|
+
void notifyError(error, 'Unhandled rejection in bot process')
|
|
1315
|
+
})
|
|
1316
|
+
}
|