@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,307 @@
|
|
|
1
|
+
// Runtime state management, file watchers, and Discord event listeners.
|
|
2
|
+
// Manages the lifecycle of forum sync: initial sync, live Discord event handling,
|
|
3
|
+
// file system watcher for bidirectional sync, and debounced sync scheduling.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import parcelWatcher from '@parcel/watcher';
|
|
6
|
+
import { ChannelType, Events, } from 'discord.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
import { readForumSyncConfig } from './config.js';
|
|
9
|
+
import { ensureDirectory, getCanonicalThreadFilePath, } from './discord-operations.js';
|
|
10
|
+
import { syncForumToFiles } from './sync-to-files.js';
|
|
11
|
+
import { syncFilesToForum } from './sync-to-discord.js';
|
|
12
|
+
import { DEFAULT_DEBOUNCE_MS, ForumSyncOperationError, addIgnoredPath, shouldIgnorePath, } from './types.js';
|
|
13
|
+
const forumLogger = createLogger('FORUM');
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
// MODULE STATE
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
const forumStateById = new Map();
|
|
18
|
+
const watcherUnsubscribeByForumId = new Map();
|
|
19
|
+
let discordListenersRegistered = false;
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
// RUNTIME STATE
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
function buildRuntimeState({ forumChannelId, outputDir, direction, }) {
|
|
24
|
+
return {
|
|
25
|
+
forumChannelId,
|
|
26
|
+
outputDir,
|
|
27
|
+
direction,
|
|
28
|
+
dirtyThreadIds: new Set(),
|
|
29
|
+
ignoredPaths: new Map(),
|
|
30
|
+
queuedFileEvents: new Map(),
|
|
31
|
+
discordDebounceTimer: null,
|
|
32
|
+
fileDebounceTimer: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
// FILE WATCHER EVENT HANDLING
|
|
37
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
38
|
+
async function runQueuedFileEvents({ runtimeState, discordClient, }) {
|
|
39
|
+
const queuedEntries = Array.from(runtimeState.queuedFileEvents.entries());
|
|
40
|
+
runtimeState.queuedFileEvents.clear();
|
|
41
|
+
if (queuedEntries.length === 0)
|
|
42
|
+
return;
|
|
43
|
+
const changedFilePaths = queuedEntries
|
|
44
|
+
.filter(([, eventType]) => eventType === 'create' || eventType === 'update')
|
|
45
|
+
.map(([filePath]) => filePath);
|
|
46
|
+
const deletedFilePaths = queuedEntries
|
|
47
|
+
.filter(([, eventType]) => eventType === 'delete')
|
|
48
|
+
.map(([filePath]) => filePath);
|
|
49
|
+
const fileSyncResult = await syncFilesToForum({
|
|
50
|
+
discordClient,
|
|
51
|
+
forumChannelId: runtimeState.forumChannelId,
|
|
52
|
+
outputDir: runtimeState.outputDir,
|
|
53
|
+
runtimeState,
|
|
54
|
+
changedFilePaths,
|
|
55
|
+
deletedFilePaths,
|
|
56
|
+
});
|
|
57
|
+
if (fileSyncResult instanceof Error) {
|
|
58
|
+
forumLogger.warn(`FS -> Discord sync failed for ${runtimeState.forumChannelId}: ${fileSyncResult.message}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (fileSyncResult.created + fileSyncResult.updated + fileSyncResult.deleted >
|
|
62
|
+
0) {
|
|
63
|
+
forumLogger.log(`FS -> Discord ${runtimeState.forumChannelId}: +${fileSyncResult.created} ~${fileSyncResult.updated} -${fileSyncResult.deleted} (skip ${fileSyncResult.skipped})`);
|
|
64
|
+
}
|
|
65
|
+
// Refresh the FS mirror for any threads that were touched
|
|
66
|
+
const discordSyncResult = await syncForumToFiles({
|
|
67
|
+
discordClient,
|
|
68
|
+
forumChannelId: runtimeState.forumChannelId,
|
|
69
|
+
outputDir: runtimeState.outputDir,
|
|
70
|
+
runtimeState,
|
|
71
|
+
forceThreadIds: runtimeState.dirtyThreadIds,
|
|
72
|
+
});
|
|
73
|
+
if (discordSyncResult instanceof Error) {
|
|
74
|
+
forumLogger.warn(`Discord -> FS refresh failed for ${runtimeState.forumChannelId}: ${discordSyncResult.message}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
runtimeState.dirtyThreadIds.clear();
|
|
78
|
+
}
|
|
79
|
+
function queueFileEvent({ runtimeState, filePath, eventType, discordClient, }) {
|
|
80
|
+
if (shouldIgnorePath({ runtimeState, filePath }))
|
|
81
|
+
return;
|
|
82
|
+
runtimeState.queuedFileEvents.set(filePath, eventType);
|
|
83
|
+
if (runtimeState.fileDebounceTimer) {
|
|
84
|
+
clearTimeout(runtimeState.fileDebounceTimer);
|
|
85
|
+
}
|
|
86
|
+
runtimeState.fileDebounceTimer = setTimeout(() => {
|
|
87
|
+
runtimeState.fileDebounceTimer = null;
|
|
88
|
+
void runQueuedFileEvents({ runtimeState, discordClient });
|
|
89
|
+
}, DEFAULT_DEBOUNCE_MS);
|
|
90
|
+
}
|
|
91
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
92
|
+
// DISCORD EVENT HANDLING
|
|
93
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
+
function scheduleDiscordSync({ runtimeState, threadId, discordClient, }) {
|
|
95
|
+
runtimeState.dirtyThreadIds.add(threadId);
|
|
96
|
+
if (runtimeState.discordDebounceTimer) {
|
|
97
|
+
clearTimeout(runtimeState.discordDebounceTimer);
|
|
98
|
+
}
|
|
99
|
+
runtimeState.discordDebounceTimer = setTimeout(() => {
|
|
100
|
+
runtimeState.discordDebounceTimer = null;
|
|
101
|
+
void (async () => {
|
|
102
|
+
const syncResult = await syncForumToFiles({
|
|
103
|
+
discordClient,
|
|
104
|
+
forumChannelId: runtimeState.forumChannelId,
|
|
105
|
+
outputDir: runtimeState.outputDir,
|
|
106
|
+
runtimeState,
|
|
107
|
+
forceThreadIds: runtimeState.dirtyThreadIds,
|
|
108
|
+
});
|
|
109
|
+
if (syncResult instanceof Error) {
|
|
110
|
+
forumLogger.warn(`Debounced Discord -> FS sync failed for ${runtimeState.forumChannelId}: ${syncResult.message}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
runtimeState.dirtyThreadIds.clear();
|
|
114
|
+
})();
|
|
115
|
+
}, DEFAULT_DEBOUNCE_MS);
|
|
116
|
+
}
|
|
117
|
+
function getThreadEventData({ channel, }) {
|
|
118
|
+
if (!channel)
|
|
119
|
+
return null;
|
|
120
|
+
if (channel.type !== ChannelType.PublicThread &&
|
|
121
|
+
channel.type !== ChannelType.PrivateThread &&
|
|
122
|
+
channel.type !== ChannelType.AnnouncementThread) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
if (!channel.parentId)
|
|
126
|
+
return null;
|
|
127
|
+
return { forumChannelId: channel.parentId, threadId: channel.id };
|
|
128
|
+
}
|
|
129
|
+
function getEventThreadFromMessage({ message, }) {
|
|
130
|
+
const channel = message.channel;
|
|
131
|
+
if (!channel || !channel.isThread())
|
|
132
|
+
return null;
|
|
133
|
+
return channel;
|
|
134
|
+
}
|
|
135
|
+
function tryHandleThreadEvent({ channel, discordClient, }) {
|
|
136
|
+
const data = getThreadEventData({ channel });
|
|
137
|
+
if (!data)
|
|
138
|
+
return;
|
|
139
|
+
const runtimeState = forumStateById.get(data.forumChannelId);
|
|
140
|
+
if (!runtimeState)
|
|
141
|
+
return;
|
|
142
|
+
scheduleDiscordSync({ runtimeState, threadId: data.threadId, discordClient });
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Find the file path for a thread, checking root and one level of subdirectories.
|
|
146
|
+
*/
|
|
147
|
+
function findThreadFilePath({ outputDir, threadId, }) {
|
|
148
|
+
const rootPath = getCanonicalThreadFilePath({ outputDir, threadId });
|
|
149
|
+
if (fs.existsSync(rootPath))
|
|
150
|
+
return rootPath;
|
|
151
|
+
const dirEntries = (() => {
|
|
152
|
+
try {
|
|
153
|
+
return fs.readdirSync(outputDir, { withFileTypes: true });
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
for (const entry of dirEntries) {
|
|
160
|
+
if (!entry.isDirectory())
|
|
161
|
+
continue;
|
|
162
|
+
const subPath = getCanonicalThreadFilePath({
|
|
163
|
+
outputDir,
|
|
164
|
+
threadId,
|
|
165
|
+
subfolder: entry.name,
|
|
166
|
+
});
|
|
167
|
+
if (fs.existsSync(subPath))
|
|
168
|
+
return subPath;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
function registerDiscordSyncListeners({ discordClient, }) {
|
|
173
|
+
if (discordListenersRegistered)
|
|
174
|
+
return;
|
|
175
|
+
discordListenersRegistered = true;
|
|
176
|
+
discordClient.on(Events.MessageCreate, (message) => {
|
|
177
|
+
if (message.author?.bot)
|
|
178
|
+
return;
|
|
179
|
+
const thread = getEventThreadFromMessage({ message });
|
|
180
|
+
tryHandleThreadEvent({ channel: thread, discordClient });
|
|
181
|
+
});
|
|
182
|
+
discordClient.on(Events.MessageUpdate, (_oldMessage, newMessage) => {
|
|
183
|
+
const thread = getEventThreadFromMessage({ message: newMessage });
|
|
184
|
+
tryHandleThreadEvent({ channel: thread, discordClient });
|
|
185
|
+
});
|
|
186
|
+
discordClient.on(Events.ThreadUpdate, (_oldThread, newThread) => {
|
|
187
|
+
tryHandleThreadEvent({ channel: newThread, discordClient });
|
|
188
|
+
});
|
|
189
|
+
discordClient.on(Events.ThreadDelete, async (thread) => {
|
|
190
|
+
const data = getThreadEventData({ channel: thread });
|
|
191
|
+
if (!data)
|
|
192
|
+
return;
|
|
193
|
+
const runtimeState = forumStateById.get(data.forumChannelId);
|
|
194
|
+
if (!runtimeState)
|
|
195
|
+
return;
|
|
196
|
+
const targetPath = findThreadFilePath({
|
|
197
|
+
outputDir: runtimeState.outputDir,
|
|
198
|
+
threadId: data.threadId,
|
|
199
|
+
});
|
|
200
|
+
if (!targetPath)
|
|
201
|
+
return;
|
|
202
|
+
addIgnoredPath({ runtimeState, filePath: targetPath });
|
|
203
|
+
await fs.promises.unlink(targetPath).catch((cause) => {
|
|
204
|
+
forumLogger.warn(`Failed to delete forum file on thread delete ${targetPath}:`, cause);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
209
|
+
// FILE WATCHER SETUP
|
|
210
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
211
|
+
async function startWatcherForRuntimeState({ runtimeState, discordClient, }) {
|
|
212
|
+
if (runtimeState.direction !== 'bidirectional')
|
|
213
|
+
return;
|
|
214
|
+
const subscription = await parcelWatcher
|
|
215
|
+
.subscribe(runtimeState.outputDir, (_error, events) => {
|
|
216
|
+
const mdEvents = events.filter((event) => event.path.endsWith('.md'));
|
|
217
|
+
mdEvents
|
|
218
|
+
.filter((event) => event.type === 'create' ||
|
|
219
|
+
event.type === 'update' ||
|
|
220
|
+
event.type === 'delete')
|
|
221
|
+
.map((event) => {
|
|
222
|
+
queueFileEvent({
|
|
223
|
+
runtimeState,
|
|
224
|
+
filePath: event.path,
|
|
225
|
+
eventType: event.type,
|
|
226
|
+
discordClient,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
})
|
|
230
|
+
.catch((cause) => new ForumSyncOperationError({
|
|
231
|
+
forumChannelId: runtimeState.forumChannelId,
|
|
232
|
+
reason: `failed to subscribe watcher for ${runtimeState.outputDir}`,
|
|
233
|
+
cause,
|
|
234
|
+
}));
|
|
235
|
+
if (subscription instanceof Error)
|
|
236
|
+
return subscription;
|
|
237
|
+
watcherUnsubscribeByForumId.set(runtimeState.forumChannelId, () => {
|
|
238
|
+
return subscription.unsubscribe();
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
242
|
+
// PUBLIC API
|
|
243
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
244
|
+
export async function stopConfiguredForumSync() {
|
|
245
|
+
const unsubscribers = Array.from(watcherUnsubscribeByForumId.values());
|
|
246
|
+
watcherUnsubscribeByForumId.clear();
|
|
247
|
+
forumStateById.clear();
|
|
248
|
+
await Promise.all(unsubscribers.map(async (unsubscribe) => {
|
|
249
|
+
await unsubscribe().catch((cause) => {
|
|
250
|
+
forumLogger.warn('Failed to unsubscribe forum watcher:', cause);
|
|
251
|
+
});
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
export async function startConfiguredForumSync({ discordClient, appId, }) {
|
|
255
|
+
const loadedConfig = await readForumSyncConfig({ appId });
|
|
256
|
+
if (loadedConfig instanceof Error)
|
|
257
|
+
return loadedConfig;
|
|
258
|
+
if (loadedConfig.length === 0)
|
|
259
|
+
return;
|
|
260
|
+
registerDiscordSyncListeners({ discordClient });
|
|
261
|
+
// Process each config independently so one stale/deleted forum channel
|
|
262
|
+
// doesn't block the watcher from starting for other valid configs.
|
|
263
|
+
for (const entry of loadedConfig) {
|
|
264
|
+
const runtimeState = buildRuntimeState({
|
|
265
|
+
forumChannelId: entry.forumChannelId,
|
|
266
|
+
outputDir: entry.outputDir,
|
|
267
|
+
direction: entry.direction,
|
|
268
|
+
});
|
|
269
|
+
forumStateById.set(entry.forumChannelId, runtimeState);
|
|
270
|
+
const ensureResult = await ensureDirectory({ directory: entry.outputDir });
|
|
271
|
+
if (ensureResult instanceof Error) {
|
|
272
|
+
forumLogger.warn(`Skipping forum ${entry.forumChannelId}: failed to create ${entry.outputDir}`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const fileToDiscordResult = await syncFilesToForum({
|
|
276
|
+
discordClient,
|
|
277
|
+
forumChannelId: entry.forumChannelId,
|
|
278
|
+
outputDir: entry.outputDir,
|
|
279
|
+
runtimeState,
|
|
280
|
+
});
|
|
281
|
+
if (fileToDiscordResult instanceof Error) {
|
|
282
|
+
forumLogger.warn(`Skipping forum ${entry.forumChannelId}: FS->Discord sync failed: ${fileToDiscordResult.message}`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const discordToFileResult = await syncForumToFiles({
|
|
286
|
+
discordClient,
|
|
287
|
+
forumChannelId: entry.forumChannelId,
|
|
288
|
+
outputDir: entry.outputDir,
|
|
289
|
+
forceFullRefresh: true,
|
|
290
|
+
runtimeState,
|
|
291
|
+
});
|
|
292
|
+
if (discordToFileResult instanceof Error) {
|
|
293
|
+
forumLogger.warn(`Skipping forum ${entry.forumChannelId}: Discord->FS sync failed: ${discordToFileResult.message}`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const watcherResult = await startWatcherForRuntimeState({
|
|
297
|
+
runtimeState,
|
|
298
|
+
discordClient,
|
|
299
|
+
});
|
|
300
|
+
if (watcherResult instanceof Error) {
|
|
301
|
+
forumLogger.warn(`Skipping forum ${entry.forumChannelId}: watcher failed: ${watcherResult.message}`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
forumLogger.log(`Forum sync started for ${entry.forumChannelId} (${entry.direction}) -> ${entry.outputDir}`);
|
|
305
|
+
forumLogger.log(`Initial sync: Discord->FS synced ${discordToFileResult.synced}, skipped ${discordToFileResult.skipped}, deleted ${discordToFileResult.deleted}; FS->Discord created ${fileToDiscordResult.created}, updated ${fileToDiscordResult.updated}, deleted ${fileToDiscordResult.deleted}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { WebSocket } from 'undici';
|
|
2
|
+
import { getDiscordWsUrl, resolveDiscordAuthHeader, } from './discord-auth.js';
|
|
3
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
4
|
+
import { notifyError } from './sentry.js';
|
|
5
|
+
const gatewayLogger = createLogger(LogPrefix.CLI);
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return typeof value === 'object' && value !== null;
|
|
8
|
+
}
|
|
9
|
+
function parseGatewayStreamMessage(parsed) {
|
|
10
|
+
if (!isRecord(parsed) || typeof parsed.type !== 'string') {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
if (parsed.type === 'connected' &&
|
|
14
|
+
typeof parsed.guild_id === 'string' &&
|
|
15
|
+
typeof parsed.machine_id === 'string' &&
|
|
16
|
+
typeof parsed.connected_at === 'string') {
|
|
17
|
+
return {
|
|
18
|
+
type: 'connected',
|
|
19
|
+
guild_id: parsed.guild_id,
|
|
20
|
+
machine_id: parsed.machine_id,
|
|
21
|
+
connected_at: parsed.connected_at,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (parsed.type === 'discord_event' &&
|
|
25
|
+
typeof parsed.guild_id === 'string' &&
|
|
26
|
+
typeof parsed.op === 'number') {
|
|
27
|
+
return {
|
|
28
|
+
type: 'discord_event',
|
|
29
|
+
guild_id: parsed.guild_id,
|
|
30
|
+
op: parsed.op,
|
|
31
|
+
t: typeof parsed.t === 'string' ? parsed.t : undefined,
|
|
32
|
+
s: typeof parsed.s === 'number' ? parsed.s : undefined,
|
|
33
|
+
d: parsed.d,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
async function decodeWebSocketData(data) {
|
|
39
|
+
if (typeof data === 'string') {
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
if (data instanceof ArrayBuffer) {
|
|
43
|
+
return Buffer.from(data).toString('utf8');
|
|
44
|
+
}
|
|
45
|
+
if (ArrayBuffer.isView(data)) {
|
|
46
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
|
|
47
|
+
}
|
|
48
|
+
if (isRecord(data) && typeof data.arrayBuffer === 'function') {
|
|
49
|
+
try {
|
|
50
|
+
const arrayBuffer = (await data.arrayBuffer());
|
|
51
|
+
return Buffer.from(arrayBuffer).toString('utf8');
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return new Error('Failed to decode websocket message body', {
|
|
55
|
+
cause: error,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return new Error('Unsupported websocket message payload type');
|
|
60
|
+
}
|
|
61
|
+
export function buildGatewayMachineWebSocketUrl(rawWsUrl) {
|
|
62
|
+
const parsed = new URL(rawWsUrl);
|
|
63
|
+
if (parsed.protocol !== 'ws:' &&
|
|
64
|
+
parsed.protocol !== 'wss:') {
|
|
65
|
+
throw new Error(`KIMAKI_DISCORD_WS_URL must use ws:// or wss://, got ${parsed.protocol}`);
|
|
66
|
+
}
|
|
67
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
68
|
+
if (path === '/ws/machine') {
|
|
69
|
+
return parsed.toString();
|
|
70
|
+
}
|
|
71
|
+
if (path === '' || path === '/') {
|
|
72
|
+
parsed.pathname = '/ws/machine';
|
|
73
|
+
return parsed.toString();
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`KIMAKI_DISCORD_WS_URL must point to /ws/machine (or base host), got ${parsed.pathname}`);
|
|
76
|
+
}
|
|
77
|
+
function bootstrapReadyState({ discordClient, appId, }) {
|
|
78
|
+
const wsManager = discordClient.ws;
|
|
79
|
+
const fakeShard = {
|
|
80
|
+
id: 0,
|
|
81
|
+
checkReady: () => {
|
|
82
|
+
if (!discordClient.isReady()) {
|
|
83
|
+
wsManager.triggerClientReady();
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const readyPacket = {
|
|
88
|
+
op: 0,
|
|
89
|
+
t: 'READY',
|
|
90
|
+
s: 0,
|
|
91
|
+
d: {
|
|
92
|
+
v: 10,
|
|
93
|
+
user: {
|
|
94
|
+
id: appId,
|
|
95
|
+
username: 'kimaki',
|
|
96
|
+
discriminator: '0000',
|
|
97
|
+
avatar: null,
|
|
98
|
+
bot: true,
|
|
99
|
+
public_flags: 0,
|
|
100
|
+
},
|
|
101
|
+
guilds: [],
|
|
102
|
+
session_id: 'kimaki-auth-mode',
|
|
103
|
+
resume_gateway_url: 'wss://gateway.discord.gg',
|
|
104
|
+
application: {
|
|
105
|
+
id: appId,
|
|
106
|
+
flags: 0,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
wsManager.handlePacket(readyPacket, fakeShard);
|
|
111
|
+
}
|
|
112
|
+
export function startGatewayAuthModeConsumer({ discordClient, guildId, appId, }) {
|
|
113
|
+
if (!process.env.KIMAKI_DISCORD_WS_URL) {
|
|
114
|
+
throw new Error('Auth mode requires KIMAKI_DISCORD_WS_URL pointing to the gateway machine websocket endpoint');
|
|
115
|
+
}
|
|
116
|
+
const authorization = resolveDiscordAuthHeader(undefined)?.value;
|
|
117
|
+
if (!authorization) {
|
|
118
|
+
throw new Error('Auth mode requires valid KIMAKI_PRIVATE_KEY and KIMAKI_GUILD_ID');
|
|
119
|
+
}
|
|
120
|
+
const wsManager = discordClient.ws;
|
|
121
|
+
const fakeShard = {
|
|
122
|
+
id: 0,
|
|
123
|
+
checkReady: () => {
|
|
124
|
+
if (!discordClient.isReady()) {
|
|
125
|
+
;
|
|
126
|
+
discordClient.ws.triggerClientReady();
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
bootstrapReadyState({ discordClient, appId });
|
|
131
|
+
const wsUrl = buildGatewayMachineWebSocketUrl(getDiscordWsUrl());
|
|
132
|
+
let socket = null;
|
|
133
|
+
let closed = false;
|
|
134
|
+
let retryDelayMs = 1_000;
|
|
135
|
+
let retryTimer = null;
|
|
136
|
+
const clearRetryTimer = () => {
|
|
137
|
+
if (retryTimer) {
|
|
138
|
+
clearTimeout(retryTimer);
|
|
139
|
+
retryTimer = null;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const scheduleReconnect = () => {
|
|
143
|
+
if (closed) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
clearRetryTimer();
|
|
147
|
+
const delay = retryDelayMs;
|
|
148
|
+
retryDelayMs = Math.min(30_000, retryDelayMs * 2);
|
|
149
|
+
retryTimer = setTimeout(() => {
|
|
150
|
+
connect();
|
|
151
|
+
}, delay);
|
|
152
|
+
};
|
|
153
|
+
const handleIncomingJson = async (text) => {
|
|
154
|
+
let parsed;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(text);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const message = parseGatewayStreamMessage(parsed);
|
|
162
|
+
if (!message) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (message.type === 'connected') {
|
|
166
|
+
gatewayLogger.log(`Auth stream connected (guild=${message.guild_id}, machine=${message.machine_id})`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (message.guild_id !== guildId) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (message.op !== 0 || !message.t) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
wsManager.handlePacket({
|
|
177
|
+
op: message.op,
|
|
178
|
+
t: message.t,
|
|
179
|
+
s: message.s,
|
|
180
|
+
d: message.d,
|
|
181
|
+
}, fakeShard);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
gatewayLogger.warn(`Failed to handle gateway event ${message.t}: ${error instanceof Error ? error.message : String(error)}`);
|
|
185
|
+
void notifyError(error, `Failed to handle gateway event ${message.t}`);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const connect = () => {
|
|
189
|
+
if (closed) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
clearRetryTimer();
|
|
193
|
+
gatewayLogger.log(`Connecting to gateway auth stream at ${wsUrl}`);
|
|
194
|
+
socket = new WebSocket(wsUrl, {
|
|
195
|
+
headers: {
|
|
196
|
+
Authorization: authorization,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
socket.addEventListener('open', () => {
|
|
200
|
+
retryDelayMs = 1_000;
|
|
201
|
+
gatewayLogger.log('Gateway auth stream connected');
|
|
202
|
+
});
|
|
203
|
+
socket.addEventListener('message', (event) => {
|
|
204
|
+
void (async () => {
|
|
205
|
+
const text = await decodeWebSocketData(event.data);
|
|
206
|
+
if (text instanceof Error) {
|
|
207
|
+
gatewayLogger.warn(text.message);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
await handleIncomingJson(text);
|
|
211
|
+
})();
|
|
212
|
+
});
|
|
213
|
+
socket.addEventListener('error', (event) => {
|
|
214
|
+
gatewayLogger.warn(`Gateway auth stream error: ${event.type || 'unknown_error'}`);
|
|
215
|
+
});
|
|
216
|
+
socket.addEventListener('close', () => {
|
|
217
|
+
if (closed) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
gatewayLogger.warn('Gateway auth stream disconnected; reconnecting...');
|
|
221
|
+
scheduleReconnect();
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
connect();
|
|
225
|
+
return () => {
|
|
226
|
+
closed = true;
|
|
227
|
+
clearRetryTimer();
|
|
228
|
+
const currentSocket = socket;
|
|
229
|
+
socket = null;
|
|
230
|
+
currentSocket?.close();
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { buildGatewayMachineWebSocketUrl } from './gateway-consumer.js';
|
|
3
|
+
describe('buildGatewayMachineWebSocketUrl', () => {
|
|
4
|
+
test('keeps explicit /ws/machine URL', () => {
|
|
5
|
+
const resolved = buildGatewayMachineWebSocketUrl('wss://gateway.example.com/ws/machine');
|
|
6
|
+
expect(resolved).toBe('wss://gateway.example.com/ws/machine');
|
|
7
|
+
});
|
|
8
|
+
test('appends /ws/machine for base host URL', () => {
|
|
9
|
+
const resolved = buildGatewayMachineWebSocketUrl('wss://gateway.example.com');
|
|
10
|
+
expect(resolved).toBe('wss://gateway.example.com/ws/machine');
|
|
11
|
+
});
|
|
12
|
+
test('rejects unsupported paths', () => {
|
|
13
|
+
expect(() => buildGatewayMachineWebSocketUrl('wss://gateway.example.com/gateway')).toThrow('/ws/machine');
|
|
14
|
+
});
|
|
15
|
+
test('rejects non-websocket protocols', () => {
|
|
16
|
+
expect(() => buildGatewayMachineWebSocketUrl('https://gateway.example.com/ws/machine')).toThrow('ws:// or wss://');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Main thread interface for the GenAI worker.
|
|
2
|
+
// Spawns and manages the worker thread, handling message passing for
|
|
3
|
+
// audio input/output, tool call completions, and graceful shutdown.
|
|
4
|
+
import { Worker } from 'node:worker_threads';
|
|
5
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
6
|
+
import { notifyError } from './sentry.js';
|
|
7
|
+
const genaiWorkerLogger = createLogger(LogPrefix.GENAI_WORKER);
|
|
8
|
+
const genaiWrapperLogger = createLogger(LogPrefix.GENAI_WORKER);
|
|
9
|
+
export function createGenAIWorker(options) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const worker = new Worker(new URL('../dist/genai-worker.js', import.meta.url));
|
|
12
|
+
// Handle messages from worker
|
|
13
|
+
worker.on('message', (message) => {
|
|
14
|
+
switch (message.type) {
|
|
15
|
+
case 'assistantOpusPacket':
|
|
16
|
+
options.onAssistantOpusPacket(message.packet);
|
|
17
|
+
break;
|
|
18
|
+
case 'assistantStartSpeaking':
|
|
19
|
+
options.onAssistantStartSpeaking?.();
|
|
20
|
+
break;
|
|
21
|
+
case 'assistantStopSpeaking':
|
|
22
|
+
options.onAssistantStopSpeaking?.();
|
|
23
|
+
break;
|
|
24
|
+
case 'assistantInterruptSpeaking':
|
|
25
|
+
options.onAssistantInterruptSpeaking?.();
|
|
26
|
+
break;
|
|
27
|
+
case 'toolCallCompleted':
|
|
28
|
+
options.onToolCallCompleted?.(message);
|
|
29
|
+
break;
|
|
30
|
+
case 'error':
|
|
31
|
+
genaiWorkerLogger.error('Error:', message.error);
|
|
32
|
+
options.onError?.(message.error);
|
|
33
|
+
break;
|
|
34
|
+
case 'ready':
|
|
35
|
+
genaiWorkerLogger.log('Ready');
|
|
36
|
+
// Resolve with the worker interface
|
|
37
|
+
resolve({
|
|
38
|
+
sendRealtimeInput({ audio, audioStreamEnd }) {
|
|
39
|
+
worker.postMessage({
|
|
40
|
+
type: 'sendRealtimeInput',
|
|
41
|
+
audio,
|
|
42
|
+
audioStreamEnd,
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
sendTextInput(text) {
|
|
46
|
+
worker.postMessage({
|
|
47
|
+
type: 'sendTextInput',
|
|
48
|
+
text,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
interrupt() {
|
|
52
|
+
worker.postMessage({
|
|
53
|
+
type: 'interrupt',
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
async stop() {
|
|
57
|
+
genaiWrapperLogger.log('Stopping worker...');
|
|
58
|
+
// Send stop message to trigger graceful shutdown
|
|
59
|
+
worker.postMessage({ type: 'stop' });
|
|
60
|
+
// Wait for worker to exit gracefully (with timeout)
|
|
61
|
+
await new Promise((resolve) => {
|
|
62
|
+
let resolved = false;
|
|
63
|
+
// Listen for worker exit
|
|
64
|
+
worker.once('exit', (code) => {
|
|
65
|
+
if (!resolved) {
|
|
66
|
+
resolved = true;
|
|
67
|
+
genaiWrapperLogger.log(`[GENAI WORKER WRAPPER] Worker exited with code ${code}`);
|
|
68
|
+
resolve();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
// Timeout after 5 seconds and force terminate
|
|
72
|
+
setTimeout(() => {
|
|
73
|
+
if (!resolved) {
|
|
74
|
+
resolved = true;
|
|
75
|
+
genaiWrapperLogger.log('[GENAI WORKER WRAPPER] Worker did not exit gracefully, terminating...');
|
|
76
|
+
worker.terminate().then(() => {
|
|
77
|
+
genaiWrapperLogger.log('Worker terminated');
|
|
78
|
+
resolve();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}, 5000);
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
// Handle worker errors
|
|
89
|
+
worker.on('error', (error) => {
|
|
90
|
+
genaiWorkerLogger.error('Worker error:', error);
|
|
91
|
+
reject(error);
|
|
92
|
+
});
|
|
93
|
+
worker.on('exit', (code) => {
|
|
94
|
+
if (code !== 0) {
|
|
95
|
+
genaiWorkerLogger.error(`Worker stopped with exit code ${code}`);
|
|
96
|
+
void notifyError(new Error(`GenAI worker exited with code ${code}`), 'GenAI worker non-zero exit after init');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
// Send initialization message
|
|
100
|
+
const initMessage = {
|
|
101
|
+
type: 'init',
|
|
102
|
+
directory: options.directory,
|
|
103
|
+
systemMessage: options.systemMessage,
|
|
104
|
+
guildId: options.guildId,
|
|
105
|
+
channelId: options.channelId,
|
|
106
|
+
appId: options.appId,
|
|
107
|
+
geminiApiKey: options.geminiApiKey,
|
|
108
|
+
};
|
|
109
|
+
worker.postMessage(initMessage);
|
|
110
|
+
});
|
|
111
|
+
}
|