@otto-assistant/bridge 0.4.92
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/agent-model.e2e.test.js +755 -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/anthropic-auth-plugin.js +728 -0
- package/dist/anthropic-auth-plugin.test.js +125 -0
- package/dist/anthropic-auth-state.js +231 -0
- package/dist/bin.js +90 -0
- package/dist/channel-management.js +227 -0
- package/dist/cli-parsing.test.js +137 -0
- package/dist/cli-send-thread.e2e.test.js +356 -0
- package/dist/cli.js +3276 -0
- package/dist/commands/abort.js +65 -0
- package/dist/commands/action-buttons.js +245 -0
- package/dist/commands/add-project.js +113 -0
- package/dist/commands/agent.js +335 -0
- package/dist/commands/ask-question.js +274 -0
- package/dist/commands/btw.js +116 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +130 -0
- package/dist/commands/diff.js +63 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +220 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +885 -0
- package/dist/commands/mcp.js +239 -0
- package/dist/commands/memory-snapshot.js +24 -0
- package/dist/commands/mention-mode.js +44 -0
- package/dist/commands/merge-worktree.js +159 -0
- package/dist/commands/model-variant.js +364 -0
- package/dist/commands/model.js +776 -0
- package/dist/commands/new-worktree.js +366 -0
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/permissions.js +274 -0
- package/dist/commands/queue.js +206 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +127 -0
- package/dist/commands/resume.js +149 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/screenshare.js +303 -0
- package/dist/commands/screenshare.test.js +20 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +176 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +305 -0
- package/dist/commands/unset-model.js +138 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +155 -0
- package/dist/commands/verbosity.js +125 -0
- package/dist/commands/worktree-settings.js +43 -0
- package/dist/commands/worktrees.js +410 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +94 -0
- package/dist/context-awareness-plugin.js +363 -0
- package/dist/context-awareness-plugin.test.js +124 -0
- package/dist/critique-utils.js +95 -0
- package/dist/database.js +1310 -0
- package/dist/db.js +251 -0
- package/dist/db.test.js +138 -0
- package/dist/debounce-timeout.js +28 -0
- package/dist/debounced-process-flush.js +77 -0
- package/dist/discord-bot.js +1008 -0
- package/dist/discord-command-registration.js +524 -0
- package/dist/discord-urls.js +81 -0
- package/dist/discord-utils.js +591 -0
- package/dist/discord-utils.test.js +134 -0
- package/dist/errors.js +157 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/event-stream-real-capture.e2e.test.js +533 -0
- package/dist/eventsource-parser.test.js +327 -0
- package/dist/exec-async.js +26 -0
- package/dist/external-opencode-sync.js +480 -0
- package/dist/format-tables.js +302 -0
- package/dist/format-tables.test.js +308 -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 +113 -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-proxy-reconnect.e2e.test.js +394 -0
- package/dist/gateway-proxy.e2e.test.js +483 -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 +37 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +52 -0
- package/dist/generated/internal/class.js +49 -0
- package/dist/generated/internal/prismaNamespace.js +253 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +223 -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_events.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 +122 -0
- package/dist/hrana-server.js +263 -0
- package/dist/hrana-server.test.js +370 -0
- package/dist/html-actions.js +123 -0
- package/dist/html-actions.test.js +70 -0
- package/dist/html-components.js +117 -0
- package/dist/html-components.test.js +34 -0
- package/dist/image-optimizer-plugin.js +153 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +397 -0
- package/dist/ipc-polling.js +252 -0
- package/dist/ipc-tools-plugin.js +193 -0
- package/dist/kimaki-digital-twin.e2e.test.js +161 -0
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
- package/dist/kimaki-opencode-plugin.js +17 -0
- package/dist/kimaki-opencode-plugin.test.js +98 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +165 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +257 -0
- package/dist/message-finish-field.e2e.test.js +165 -0
- package/dist/message-formatting.js +413 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/message-preprocessing.js +330 -0
- package/dist/onboarding-tutorial.js +172 -0
- package/dist/onboarding-welcome.js +37 -0
- package/dist/openai-realtime.js +224 -0
- package/dist/opencode-command-detection.js +65 -0
- package/dist/opencode-command-detection.test.js +240 -0
- package/dist/opencode-command.js +129 -0
- package/dist/opencode-command.test.js +48 -0
- package/dist/opencode-interrupt-plugin.js +361 -0
- package/dist/opencode-interrupt-plugin.test.js +458 -0
- package/dist/opencode.js +861 -0
- package/dist/otto/branding.js +22 -0
- package/dist/otto/index.js +21 -0
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/patch-text-parser.js +97 -0
- package/dist/plugin-logger.js +59 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/queue-advanced-abort.e2e.test.js +293 -0
- package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
- package/dist/queue-advanced-e2e-setup.js +786 -0
- package/dist/queue-advanced-footer.e2e.test.js +472 -0
- package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
- package/dist/queue-advanced-question.e2e.test.js +261 -0
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
- package/dist/queue-advanced-typing.e2e.test.js +153 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/queue-interrupt-drain.e2e.test.js +135 -0
- package/dist/queue-question-select-drain.e2e.test.js +120 -0
- package/dist/runtime-idle-sweeper.js +52 -0
- package/dist/runtime-lifecycle.e2e.test.js +508 -0
- package/dist/sentry.js +23 -0
- package/dist/session-handler/agent-utils.js +67 -0
- package/dist/session-handler/event-stream-state.js +420 -0
- package/dist/session-handler/event-stream-state.test.js +563 -0
- package/dist/session-handler/model-utils.js +124 -0
- package/dist/session-handler/opencode-session-event-log.js +94 -0
- package/dist/session-handler/thread-runtime-state.js +104 -0
- package/dist/session-handler/thread-session-runtime.js +3258 -0
- package/dist/session-handler.js +9 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/session-title-rename.test.js +80 -0
- package/dist/startup-service.js +153 -0
- package/dist/startup-time.e2e.test.js +296 -0
- package/dist/store.js +17 -0
- package/dist/system-message.js +613 -0
- package/dist/system-message.test.js +602 -0
- package/dist/task-runner.js +295 -0
- package/dist/task-schedule.js +209 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/test-utils.js +299 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +999 -0
- package/dist/tools.js +357 -0
- package/dist/undo-redo.e2e.test.js +161 -0
- package/dist/unnest-code-blocks.js +146 -0
- package/dist/unnest-code-blocks.test.js +673 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +144 -0
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +646 -0
- package/dist/voice-message.e2e.test.js +1021 -0
- package/dist/voice.js +447 -0
- package/dist/voice.test.js +235 -0
- package/dist/wait-session.js +94 -0
- package/dist/websockify.js +69 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-lifecycle.e2e.test.js +308 -0
- package/dist/worktree-utils.js +3 -0
- package/dist/worktrees.js +929 -0
- package/dist/worktrees.test.js +189 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +98 -0
- package/schema.prisma +295 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +112 -0
- package/skills/egaki/SKILL.md +100 -0
- package/skills/errore/SKILL.md +647 -0
- package/skills/event-sourcing-state/SKILL.md +252 -0
- package/skills/gitchamber/SKILL.md +93 -0
- package/skills/goke/SKILL.md +644 -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/lintcn/SKILL.md +873 -0
- package/skills/new-skill/SKILL.md +211 -0
- package/skills/npm-package/SKILL.md +239 -0
- package/skills/playwriter/SKILL.md +35 -0
- package/skills/proxyman/SKILL.md +215 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/usecomputer/SKILL.md +264 -0
- package/skills/x-articles/SKILL.md +554 -0
- package/skills/zele/SKILL.md +112 -0
- package/skills/zustand-centralized-state/SKILL.md +1004 -0
- package/src/agent-model.e2e.test.ts +976 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +283 -0
- package/src/ai-tool.ts +39 -0
- package/src/anthropic-auth-plugin.test.ts +159 -0
- package/src/anthropic-auth-plugin.ts +861 -0
- package/src/anthropic-auth-state.ts +282 -0
- package/src/bin.ts +111 -0
- package/src/channel-management.ts +334 -0
- package/src/cli-parsing.test.ts +195 -0
- package/src/cli-send-thread.e2e.test.ts +464 -0
- package/src/cli.ts +4581 -0
- package/src/commands/abort.ts +89 -0
- package/src/commands/action-buttons.ts +364 -0
- package/src/commands/add-project.ts +149 -0
- package/src/commands/agent.ts +473 -0
- package/src/commands/ask-question.ts +390 -0
- package/src/commands/btw.ts +164 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +190 -0
- package/src/commands/diff.ts +91 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +321 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +1173 -0
- package/src/commands/mcp.ts +307 -0
- package/src/commands/memory-snapshot.ts +30 -0
- package/src/commands/mention-mode.ts +68 -0
- package/src/commands/merge-worktree.ts +223 -0
- package/src/commands/model-variant.ts +483 -0
- package/src/commands/model.ts +1053 -0
- package/src/commands/new-worktree.ts +510 -0
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/permissions.ts +397 -0
- package/src/commands/queue.ts +271 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/screenshare.test.ts +30 -0
- package/src/commands/screenshare.ts +366 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +227 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/tasks.ts +293 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +386 -0
- package/src/commands/unset-model.ts +173 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +198 -0
- package/src/commands/verbosity.ts +173 -0
- package/src/commands/worktree-settings.ts +70 -0
- package/src/commands/worktrees.ts +552 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +111 -0
- package/src/context-awareness-plugin.test.ts +142 -0
- package/src/context-awareness-plugin.ts +510 -0
- package/src/critique-utils.ts +139 -0
- package/src/database.ts +1876 -0
- package/src/db.test.ts +162 -0
- package/src/db.ts +286 -0
- package/src/debounce-timeout.ts +43 -0
- package/src/debounced-process-flush.ts +104 -0
- package/src/discord-bot.ts +1330 -0
- package/src/discord-command-registration.ts +693 -0
- package/src/discord-urls.ts +88 -0
- package/src/discord-utils.test.ts +153 -0
- package/src/discord-utils.ts +800 -0
- package/src/errors.ts +201 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/event-stream-real-capture.e2e.test.ts +692 -0
- package/src/eventsource-parser.test.ts +351 -0
- package/src/exec-async.ts +35 -0
- package/src/external-opencode-sync.ts +685 -0
- package/src/format-tables.test.ts +335 -0
- package/src/format-tables.ts +445 -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 +172 -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/gateway-proxy-reconnect.e2e.test.ts +523 -0
- package/src/gateway-proxy.e2e.test.ts +640 -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 +114 -0
- package/src/generated/client.ts +138 -0
- package/src/generated/commonInputTypes.ts +736 -0
- package/src/generated/enums.ts +88 -0
- package/src/generated/internal/class.ts +384 -0
- package/src/generated/internal/prismaNamespace.ts +2386 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1656 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +1859 -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 +1228 -0
- package/src/generated/models/channel_worktrees.ts +1300 -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_events.ts +1439 -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 +1781 -0
- package/src/generated/models/thread_worktrees.ts +1356 -0
- package/src/generated/models.ts +30 -0
- package/src/heap-monitor.ts +152 -0
- package/src/hrana-server.test.ts +434 -0
- package/src/hrana-server.ts +314 -0
- package/src/html-actions.test.ts +87 -0
- package/src/html-actions.ts +174 -0
- package/src/html-components.test.ts +38 -0
- package/src/html-components.ts +181 -0
- package/src/image-optimizer-plugin.ts +194 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +576 -0
- package/src/ipc-polling.ts +326 -0
- package/src/ipc-tools-plugin.ts +236 -0
- package/src/kimaki-digital-twin.e2e.test.ts +199 -0
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
- package/src/kimaki-opencode-plugin.test.ts +108 -0
- package/src/kimaki-opencode-plugin.ts +18 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +208 -0
- package/src/markdown.test.ts +308 -0
- package/src/markdown.ts +410 -0
- package/src/message-finish-field.e2e.test.ts +192 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +533 -0
- package/src/message-preprocessing.ts +455 -0
- package/src/onboarding-tutorial.ts +176 -0
- package/src/onboarding-welcome.ts +49 -0
- package/src/openai-realtime.ts +358 -0
- package/src/opencode-command-detection.test.ts +307 -0
- package/src/opencode-command-detection.ts +76 -0
- package/src/opencode-command.test.ts +70 -0
- package/src/opencode-command.ts +188 -0
- package/src/opencode-interrupt-plugin.test.ts +677 -0
- package/src/opencode-interrupt-plugin.ts +477 -0
- package/src/opencode.ts +1110 -0
- package/src/otto/branding.ts +23 -0
- package/src/otto/index.ts +22 -0
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/patch-text-parser.ts +107 -0
- package/src/plugin-logger.ts +68 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/queue-advanced-abort.e2e.test.ts +382 -0
- package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
- package/src/queue-advanced-e2e-setup.ts +873 -0
- package/src/queue-advanced-footer.e2e.test.ts +576 -0
- package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
- package/src/queue-advanced-question.e2e.test.ts +316 -0
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
- package/src/queue-advanced-typing.e2e.test.ts +199 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/queue-interrupt-drain.e2e.test.ts +166 -0
- package/src/queue-question-select-drain.e2e.test.ts +152 -0
- package/src/runtime-idle-sweeper.ts +76 -0
- package/src/runtime-lifecycle.e2e.test.ts +641 -0
- package/src/schema.sql +173 -0
- package/src/sentry.ts +26 -0
- package/src/session-handler/agent-utils.ts +97 -0
- package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
- package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
- package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
- package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
- package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
- package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
- package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
- package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
- package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
- package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
- package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
- package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
- package/src/session-handler/event-stream-state.test.ts +645 -0
- package/src/session-handler/event-stream-state.ts +608 -0
- package/src/session-handler/model-utils.ts +183 -0
- package/src/session-handler/opencode-session-event-log.ts +130 -0
- package/src/session-handler/thread-runtime-state.ts +212 -0
- package/src/session-handler/thread-session-runtime.ts +4281 -0
- package/src/session-handler.ts +15 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/session-title-rename.test.ts +112 -0
- package/src/startup-service.ts +200 -0
- package/src/startup-time.e2e.test.ts +373 -0
- package/src/store.ts +122 -0
- package/src/system-message.test.ts +612 -0
- package/src/system-message.ts +723 -0
- package/src/task-runner.ts +421 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +311 -0
- package/src/test-utils.ts +435 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +1219 -0
- package/src/tools.ts +430 -0
- package/src/undici.d.ts +12 -0
- package/src/undo-redo.e2e.test.ts +209 -0
- package/src/unnest-code-blocks.test.ts +713 -0
- package/src/unnest-code-blocks.ts +185 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +212 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +908 -0
- package/src/voice-message.e2e.test.ts +1255 -0
- package/src/voice.test.ts +281 -0
- package/src/voice.ts +627 -0
- package/src/wait-session.ts +147 -0
- package/src/websockify.ts +101 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-lifecycle.e2e.test.ts +391 -0
- package/src/worktree-utils.ts +4 -0
- package/src/worktrees.test.ts +223 -0
- package/src/worktrees.ts +1294 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Discord -> filesystem sync.
|
|
2
|
+
// Fetches forum threads from Discord and writes them as markdown files.
|
|
3
|
+
// Handles incremental sync (skip unchanged threads) and stale file cleanup.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createLogger } from '../logger.js';
|
|
7
|
+
import { buildMessageSections, extractProjectChannelFromContent, formatMessageSection, getStringValue, stringifyFrontmatter, } from './markdown.js';
|
|
8
|
+
import { ensureDirectory, fetchForumThreads, fetchThreadMessages, getCanonicalThreadFilePath, loadExistingForumFiles, resolveForumChannel, } from './discord-operations.js';
|
|
9
|
+
import { DEFAULT_RATE_LIMIT_DELAY_MS, ForumSyncOperationError, addIgnoredPath, delay, } from './types.js';
|
|
10
|
+
const forumLogger = createLogger('FORUM');
|
|
11
|
+
function resolveTagNames({ thread, forumChannel, }) {
|
|
12
|
+
const availableTagsById = new Map(forumChannel.availableTags.map((tag) => [tag.id, tag.name]));
|
|
13
|
+
return thread.appliedTags
|
|
14
|
+
.map((tagId) => availableTagsById.get(tagId))
|
|
15
|
+
.filter((tagName) => Boolean(tagName));
|
|
16
|
+
}
|
|
17
|
+
function resolveSubfolderForThread({ existingSubfolder, thread, forumChannel, }) {
|
|
18
|
+
const hasGlobalTag = resolveTagNames({ thread, forumChannel }).some((tagName) => tagName.toLowerCase().trim() === 'global');
|
|
19
|
+
if (hasGlobalTag)
|
|
20
|
+
return 'global';
|
|
21
|
+
if (existingSubfolder)
|
|
22
|
+
return existingSubfolder;
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
function buildFrontmatter({ thread, forumChannel, sections, project, projectChannelId, }) {
|
|
26
|
+
const firstSection = sections[0];
|
|
27
|
+
const createdTimestamp = thread.createdTimestamp ?? Date.now();
|
|
28
|
+
const latestTimestamp = sections.reduce((latest, section) => {
|
|
29
|
+
const created = Date.parse(section.createdAt);
|
|
30
|
+
const edited = section.editedAt ? Date.parse(section.editedAt) : 0;
|
|
31
|
+
return Math.max(latest, created, edited);
|
|
32
|
+
}, createdTimestamp);
|
|
33
|
+
return {
|
|
34
|
+
title: thread.name,
|
|
35
|
+
threadId: thread.id,
|
|
36
|
+
forumChannelId: forumChannel.id,
|
|
37
|
+
tags: resolveTagNames({ thread, forumChannel }),
|
|
38
|
+
author: firstSection?.authorName || '',
|
|
39
|
+
authorId: firstSection?.authorId || '',
|
|
40
|
+
createdAt: thread.createdAt?.toISOString() ||
|
|
41
|
+
new Date(createdTimestamp).toISOString(),
|
|
42
|
+
lastUpdated: new Date(latestTimestamp).toISOString(),
|
|
43
|
+
lastMessageId: thread.lastMessageId,
|
|
44
|
+
lastSyncedAt: new Date().toISOString(),
|
|
45
|
+
messageCount: sections.length,
|
|
46
|
+
...(project && { project }),
|
|
47
|
+
...(projectChannelId && { projectChannelId }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function syncSingleThreadToFile({ thread, forumChannel, outputDir, runtimeState, previousFilePath, subfolder, project, projectChannelId, }) {
|
|
51
|
+
const messages = await fetchThreadMessages({ thread });
|
|
52
|
+
if (messages instanceof Error)
|
|
53
|
+
return messages;
|
|
54
|
+
// Extract projectChannelId from the starter message footer if not already known.
|
|
55
|
+
// This allows Discord -> file sync to reconstruct the correct subfolder
|
|
56
|
+
// even when no local .md file exists (e.g. fresh machine, deleted files).
|
|
57
|
+
let resolvedProjectChannelId = projectChannelId;
|
|
58
|
+
let resolvedSubfolder = subfolder;
|
|
59
|
+
const sections = buildMessageSections({ messages });
|
|
60
|
+
const firstSection = sections[0];
|
|
61
|
+
if (firstSection) {
|
|
62
|
+
const { cleanContent, projectChannelId: footerChannelId } = extractProjectChannelFromContent({ content: firstSection.content });
|
|
63
|
+
firstSection.content = cleanContent;
|
|
64
|
+
if (footerChannelId && !resolvedProjectChannelId) {
|
|
65
|
+
resolvedProjectChannelId = footerChannelId;
|
|
66
|
+
}
|
|
67
|
+
if (resolvedProjectChannelId && !resolvedSubfolder) {
|
|
68
|
+
resolvedSubfolder = resolvedProjectChannelId;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Ensure subfolder directory exists when writing into a nested path
|
|
72
|
+
if (resolvedSubfolder) {
|
|
73
|
+
const subDir = path.join(outputDir, resolvedSubfolder);
|
|
74
|
+
const ensureResult = await ensureDirectory({ directory: subDir });
|
|
75
|
+
if (ensureResult instanceof Error)
|
|
76
|
+
return ensureResult;
|
|
77
|
+
}
|
|
78
|
+
const body = sections
|
|
79
|
+
.map((section) => formatMessageSection({ section }))
|
|
80
|
+
.join('\n\n---\n\n');
|
|
81
|
+
const frontmatter = buildFrontmatter({
|
|
82
|
+
thread,
|
|
83
|
+
forumChannel,
|
|
84
|
+
sections,
|
|
85
|
+
project,
|
|
86
|
+
projectChannelId: resolvedProjectChannelId,
|
|
87
|
+
});
|
|
88
|
+
const markdown = stringifyFrontmatter({ frontmatter, body });
|
|
89
|
+
const targetPath = getCanonicalThreadFilePath({
|
|
90
|
+
outputDir,
|
|
91
|
+
threadId: thread.id,
|
|
92
|
+
subfolder: resolvedSubfolder,
|
|
93
|
+
});
|
|
94
|
+
addIgnoredPath({ runtimeState, filePath: targetPath });
|
|
95
|
+
const writeResult = await fs.promises
|
|
96
|
+
.writeFile(targetPath, markdown, 'utf8')
|
|
97
|
+
.catch((cause) => {
|
|
98
|
+
return new ForumSyncOperationError({
|
|
99
|
+
forumChannelId: forumChannel.id,
|
|
100
|
+
reason: `failed to write ${targetPath}`,
|
|
101
|
+
cause,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
if (writeResult instanceof Error)
|
|
105
|
+
return writeResult;
|
|
106
|
+
// Clean up old file if thread was renamed (file path changed)
|
|
107
|
+
if (previousFilePath &&
|
|
108
|
+
previousFilePath !== targetPath &&
|
|
109
|
+
fs.existsSync(previousFilePath)) {
|
|
110
|
+
addIgnoredPath({ runtimeState, filePath: previousFilePath });
|
|
111
|
+
await fs.promises.unlink(previousFilePath).catch((cause) => {
|
|
112
|
+
forumLogger.warn(`Failed to remove old forum file ${previousFilePath}:`, cause);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export async function syncForumToFiles({ discordClient, forumChannelId, outputDir, forceFullRefresh = false, forceThreadIds, runtimeState, }) {
|
|
117
|
+
const ensureResult = await ensureDirectory({ directory: outputDir });
|
|
118
|
+
if (ensureResult instanceof Error) {
|
|
119
|
+
return new ForumSyncOperationError({
|
|
120
|
+
forumChannelId,
|
|
121
|
+
reason: `failed to create output directory ${outputDir}`,
|
|
122
|
+
cause: ensureResult,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const forumChannel = await resolveForumChannel({
|
|
126
|
+
discordClient,
|
|
127
|
+
forumChannelId,
|
|
128
|
+
});
|
|
129
|
+
if (forumChannel instanceof Error)
|
|
130
|
+
return forumChannel;
|
|
131
|
+
const threads = await fetchForumThreads({ forumChannel });
|
|
132
|
+
if (threads instanceof Error)
|
|
133
|
+
return threads;
|
|
134
|
+
const existingFiles = await loadExistingForumFiles({ outputDir });
|
|
135
|
+
const existingByThreadId = new Map(existingFiles.map((entry) => [entry.threadId, entry]));
|
|
136
|
+
const result = { synced: 0, skipped: 0, deleted: 0 };
|
|
137
|
+
for (const thread of threads) {
|
|
138
|
+
const existing = existingByThreadId.get(thread.id);
|
|
139
|
+
const savedLastMessageId = getStringValue({ value: existing?.frontmatter.lastMessageId }) || null;
|
|
140
|
+
const isForced = forceFullRefresh || Boolean(forceThreadIds?.has(thread.id));
|
|
141
|
+
if (!isForced &&
|
|
142
|
+
savedLastMessageId &&
|
|
143
|
+
savedLastMessageId === thread.lastMessageId) {
|
|
144
|
+
result.skipped += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const syncResult = await syncSingleThreadToFile({
|
|
148
|
+
thread,
|
|
149
|
+
forumChannel,
|
|
150
|
+
outputDir,
|
|
151
|
+
runtimeState,
|
|
152
|
+
previousFilePath: existing?.filePath,
|
|
153
|
+
subfolder: resolveSubfolderForThread({
|
|
154
|
+
existingSubfolder: existing?.subfolder,
|
|
155
|
+
thread,
|
|
156
|
+
forumChannel,
|
|
157
|
+
}),
|
|
158
|
+
project: getStringValue({ value: existing?.frontmatter.project }),
|
|
159
|
+
projectChannelId: getStringValue({
|
|
160
|
+
value: existing?.frontmatter.projectChannelId,
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
if (syncResult instanceof Error)
|
|
164
|
+
return syncResult;
|
|
165
|
+
result.synced += 1;
|
|
166
|
+
await delay({ ms: DEFAULT_RATE_LIMIT_DELAY_MS });
|
|
167
|
+
}
|
|
168
|
+
// Delete files for threads that no longer exist in Discord
|
|
169
|
+
const liveThreadIds = new Set(threads.map((thread) => thread.id));
|
|
170
|
+
for (const existing of existingFiles) {
|
|
171
|
+
if (liveThreadIds.has(existing.threadId))
|
|
172
|
+
continue;
|
|
173
|
+
if (!fs.existsSync(existing.filePath))
|
|
174
|
+
continue;
|
|
175
|
+
addIgnoredPath({ runtimeState, filePath: existing.filePath });
|
|
176
|
+
const deleteResult = await fs.promises
|
|
177
|
+
.unlink(existing.filePath)
|
|
178
|
+
.catch((cause) => {
|
|
179
|
+
return new ForumSyncOperationError({
|
|
180
|
+
forumChannelId,
|
|
181
|
+
reason: `failed deleting stale file ${existing.filePath}`,
|
|
182
|
+
cause,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
if (deleteResult instanceof Error)
|
|
186
|
+
return deleteResult;
|
|
187
|
+
result.deleted += 1;
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Type definitions, tagged errors, and constants for forum sync.
|
|
2
|
+
// All shared types and error classes live here to avoid circular dependencies
|
|
3
|
+
// between the sync modules.
|
|
4
|
+
import * as errore from 'errore';
|
|
5
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
// CONSTANTS
|
|
7
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
export const DEFAULT_DEBOUNCE_MS = 800;
|
|
9
|
+
export const DEFAULT_RATE_LIMIT_DELAY_MS = 250;
|
|
10
|
+
export const WRITE_IGNORE_TTL_MS = 2_000;
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
// TAGGED ERRORS
|
|
13
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
+
export class ForumChannelResolveError extends errore.createTaggedError({
|
|
15
|
+
name: 'ForumChannelResolveError',
|
|
16
|
+
message: 'Could not resolve forum channel $forumChannelId',
|
|
17
|
+
}) {
|
|
18
|
+
}
|
|
19
|
+
export class ForumSyncOperationError extends errore.createTaggedError({
|
|
20
|
+
name: 'ForumSyncOperationError',
|
|
21
|
+
message: 'Forum sync operation failed for forum $forumChannelId: $reason',
|
|
22
|
+
}) {
|
|
23
|
+
}
|
|
24
|
+
export class ForumFrontmatterParseError extends errore.createTaggedError({
|
|
25
|
+
name: 'ForumFrontmatterParseError',
|
|
26
|
+
message: 'Failed to parse frontmatter: $reason',
|
|
27
|
+
}) {
|
|
28
|
+
}
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
30
|
+
// SHARED UTILITIES
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
export function delay({ ms }) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
setTimeout(resolve, ms);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/** Mark a file path as recently written so the file watcher ignores it. */
|
|
38
|
+
export function addIgnoredPath({ runtimeState, filePath, }) {
|
|
39
|
+
if (!runtimeState)
|
|
40
|
+
return;
|
|
41
|
+
runtimeState.ignoredPaths.set(filePath, Date.now() + WRITE_IGNORE_TTL_MS);
|
|
42
|
+
}
|
|
43
|
+
/** Check if a file path was recently written by us and should be ignored. */
|
|
44
|
+
export function shouldIgnorePath({ runtimeState, filePath, }) {
|
|
45
|
+
const expiresAt = runtimeState.ignoredPaths.get(filePath);
|
|
46
|
+
if (!expiresAt)
|
|
47
|
+
return false;
|
|
48
|
+
if (expiresAt < Date.now()) {
|
|
49
|
+
runtimeState.ignoredPaths.delete(filePath);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
@@ -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
|
+
}
|