@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,154 @@
|
|
|
1
|
+
// Discord API operations for forum sync.
|
|
2
|
+
// Resolves forum channels, fetches threads (active + archived) with pagination,
|
|
3
|
+
// fetches thread messages, loads existing forum files from disk, and ensures directories.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { ChannelType, } from 'discord.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
import { parseFrontmatter, getStringValue } from './markdown.js';
|
|
9
|
+
import { DEFAULT_RATE_LIMIT_DELAY_MS, ForumChannelResolveError, ForumSyncOperationError, delay, } from './types.js';
|
|
10
|
+
const forumLogger = createLogger('FORUM');
|
|
11
|
+
export function getCanonicalThreadFilePath({ outputDir, threadId, subfolder, }) {
|
|
12
|
+
if (subfolder) {
|
|
13
|
+
return path.join(outputDir, subfolder, `${threadId}.md`);
|
|
14
|
+
}
|
|
15
|
+
return path.join(outputDir, `${threadId}.md`);
|
|
16
|
+
}
|
|
17
|
+
export async function ensureDirectory({ directory }) {
|
|
18
|
+
const result = await fs.promises.mkdir(directory, { recursive: true }).catch((cause) => new ForumSyncOperationError({
|
|
19
|
+
forumChannelId: 'unknown',
|
|
20
|
+
reason: directory,
|
|
21
|
+
cause,
|
|
22
|
+
}));
|
|
23
|
+
if (result instanceof Error)
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
export async function resolveForumChannel({ discordClient, forumChannelId, }) {
|
|
27
|
+
const channel = await discordClient.channels
|
|
28
|
+
.fetch(forumChannelId)
|
|
29
|
+
.catch((cause) => new ForumChannelResolveError({ forumChannelId, cause }));
|
|
30
|
+
if (channel instanceof Error)
|
|
31
|
+
return channel;
|
|
32
|
+
if (!channel || channel.type !== ChannelType.GuildForum) {
|
|
33
|
+
return new ForumChannelResolveError({ forumChannelId });
|
|
34
|
+
}
|
|
35
|
+
return channel;
|
|
36
|
+
}
|
|
37
|
+
export async function fetchForumThreads({ forumChannel, }) {
|
|
38
|
+
const byId = new Map();
|
|
39
|
+
const active = await forumChannel.threads.fetchActive().catch((cause) => new ForumSyncOperationError({
|
|
40
|
+
forumChannelId: forumChannel.id,
|
|
41
|
+
reason: 'fetchActive failed',
|
|
42
|
+
cause,
|
|
43
|
+
}));
|
|
44
|
+
if (active instanceof Error)
|
|
45
|
+
return active;
|
|
46
|
+
for (const [id, thread] of active.threads) {
|
|
47
|
+
byId.set(id, thread);
|
|
48
|
+
}
|
|
49
|
+
let before;
|
|
50
|
+
while (true) {
|
|
51
|
+
const archived = await forumChannel.threads
|
|
52
|
+
.fetchArchived({ type: 'public', limit: 100, before })
|
|
53
|
+
.catch((cause) => new ForumSyncOperationError({
|
|
54
|
+
forumChannelId: forumChannel.id,
|
|
55
|
+
reason: 'fetchArchived failed',
|
|
56
|
+
cause,
|
|
57
|
+
}));
|
|
58
|
+
if (archived instanceof Error)
|
|
59
|
+
return archived;
|
|
60
|
+
const threads = Array.from(archived.threads.values());
|
|
61
|
+
for (const thread of threads) {
|
|
62
|
+
byId.set(thread.id, thread);
|
|
63
|
+
}
|
|
64
|
+
if (!archived.hasMore || threads.length === 0)
|
|
65
|
+
break;
|
|
66
|
+
const timestamps = threads
|
|
67
|
+
.map((thread) => thread.archiveTimestamp ?? thread.createdTimestamp)
|
|
68
|
+
.filter((value) => value !== null);
|
|
69
|
+
const oldestTimestamp = Math.min(...timestamps);
|
|
70
|
+
if (!Number.isFinite(oldestTimestamp))
|
|
71
|
+
break;
|
|
72
|
+
before = new Date(oldestTimestamp - 1);
|
|
73
|
+
await delay({ ms: DEFAULT_RATE_LIMIT_DELAY_MS });
|
|
74
|
+
}
|
|
75
|
+
return Array.from(byId.values());
|
|
76
|
+
}
|
|
77
|
+
export async function fetchThreadMessages({ thread, }) {
|
|
78
|
+
const byId = new Map();
|
|
79
|
+
let before;
|
|
80
|
+
while (true) {
|
|
81
|
+
const fetched = await thread.messages.fetch({ limit: 100, before }).catch((cause) => new ForumSyncOperationError({
|
|
82
|
+
forumChannelId: thread.parentId || 'unknown',
|
|
83
|
+
reason: `message fetch failed for thread ${thread.id}`,
|
|
84
|
+
cause,
|
|
85
|
+
}));
|
|
86
|
+
if (fetched instanceof Error)
|
|
87
|
+
return fetched;
|
|
88
|
+
const messages = Array.from(fetched.values());
|
|
89
|
+
for (const message of messages) {
|
|
90
|
+
byId.set(message.id, message);
|
|
91
|
+
}
|
|
92
|
+
if (messages.length < 100 || messages.length === 0)
|
|
93
|
+
break;
|
|
94
|
+
// Find oldest message for cursor - messages are sorted by Discord, last is oldest
|
|
95
|
+
const oldest = messages[messages.length - 1];
|
|
96
|
+
if (!oldest)
|
|
97
|
+
break;
|
|
98
|
+
before = oldest.id;
|
|
99
|
+
await delay({ ms: DEFAULT_RATE_LIMIT_DELAY_MS });
|
|
100
|
+
}
|
|
101
|
+
return Array.from(byId.values()).sort((a, b) => a.createdTimestamp - b.createdTimestamp);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Recursively walks a directory collecting all .md files with their relative subfolder path.
|
|
105
|
+
*/
|
|
106
|
+
async function collectMarkdownFiles({ dir, outputDir, }) {
|
|
107
|
+
if (!fs.existsSync(dir))
|
|
108
|
+
return [];
|
|
109
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
110
|
+
const relativeSub = path.relative(outputDir, dir);
|
|
111
|
+
const subfolder = relativeSub && relativeSub !== '.' ? relativeSub : undefined;
|
|
112
|
+
const mdFiles = entries
|
|
113
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
114
|
+
.map((entry) => ({ filePath: path.join(dir, entry.name), subfolder }));
|
|
115
|
+
const subdirs = entries.filter((entry) => entry.isDirectory());
|
|
116
|
+
const nestedResults = await Promise.all(subdirs.map((subdir) => collectMarkdownFiles({
|
|
117
|
+
dir: path.join(dir, subdir.name),
|
|
118
|
+
outputDir,
|
|
119
|
+
})));
|
|
120
|
+
return [...mdFiles, ...nestedResults.flat()];
|
|
121
|
+
}
|
|
122
|
+
export async function loadExistingForumFiles({ outputDir, }) {
|
|
123
|
+
const markdownEntries = await collectMarkdownFiles({
|
|
124
|
+
dir: outputDir,
|
|
125
|
+
outputDir,
|
|
126
|
+
});
|
|
127
|
+
const loaded = await Promise.all(markdownEntries.map(async ({ filePath, subfolder }) => {
|
|
128
|
+
const content = await fs.promises
|
|
129
|
+
.readFile(filePath, 'utf8')
|
|
130
|
+
.catch((cause) => {
|
|
131
|
+
forumLogger.warn(`Failed to read forum file ${filePath}:`, cause);
|
|
132
|
+
return null;
|
|
133
|
+
});
|
|
134
|
+
if (content === null)
|
|
135
|
+
return null;
|
|
136
|
+
const parsed = parseFrontmatter({ markdown: content });
|
|
137
|
+
const threadIdFromFrontmatter = getStringValue({
|
|
138
|
+
value: parsed.frontmatter.threadId,
|
|
139
|
+
});
|
|
140
|
+
const threadIdFromFilename = path.basename(filePath, '.md');
|
|
141
|
+
const threadId = threadIdFromFrontmatter ||
|
|
142
|
+
(/^\d+$/.test(threadIdFromFilename) ? threadIdFromFilename : '');
|
|
143
|
+
if (!threadId)
|
|
144
|
+
return null;
|
|
145
|
+
const result = {
|
|
146
|
+
filePath,
|
|
147
|
+
threadId,
|
|
148
|
+
frontmatter: parsed.frontmatter,
|
|
149
|
+
subfolder,
|
|
150
|
+
};
|
|
151
|
+
return result;
|
|
152
|
+
}));
|
|
153
|
+
return loaded.filter((item) => item !== null);
|
|
154
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Forum sync module entry point.
|
|
2
|
+
// Re-exports the public API for forum <-> markdown synchronization.
|
|
3
|
+
export { startConfiguredForumSync, stopConfiguredForumSync, } from './watchers.js';
|
|
4
|
+
export { syncForumToFiles } from './sync-to-files.js';
|
|
5
|
+
export { syncFilesToForum } from './sync-to-discord.js';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Markdown parsing, serialization, and section formatting for forum sync.
|
|
2
|
+
// Handles frontmatter extraction, message section building, and
|
|
3
|
+
// conversion between Discord messages and markdown format.
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import * as errore from 'errore';
|
|
6
|
+
import { ForumFrontmatterParseError, } from './types.js';
|
|
7
|
+
export function toStringArray({ value }) {
|
|
8
|
+
if (!Array.isArray(value))
|
|
9
|
+
return [];
|
|
10
|
+
return value.filter((item) => typeof item === 'string');
|
|
11
|
+
}
|
|
12
|
+
export function getStringValue({ value }) {
|
|
13
|
+
if (typeof value !== 'string')
|
|
14
|
+
return '';
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function parseFrontmatter({ markdown, }) {
|
|
18
|
+
if (!markdown.startsWith('---\n')) {
|
|
19
|
+
return { frontmatter: {}, body: markdown.trim() };
|
|
20
|
+
}
|
|
21
|
+
const end = markdown.indexOf('\n---\n', 4);
|
|
22
|
+
if (end === -1) {
|
|
23
|
+
return { frontmatter: {}, body: markdown.trim() };
|
|
24
|
+
}
|
|
25
|
+
const rawFrontmatter = markdown.slice(4, end);
|
|
26
|
+
const body = markdown.slice(end + 5).trim();
|
|
27
|
+
const parsed = errore.try({
|
|
28
|
+
try: () => YAML.parse(rawFrontmatter),
|
|
29
|
+
catch: (cause) => new ForumFrontmatterParseError({ reason: 'yaml parse failed', cause }),
|
|
30
|
+
});
|
|
31
|
+
if (parsed instanceof Error || !parsed || typeof parsed !== 'object') {
|
|
32
|
+
return { frontmatter: {}, body };
|
|
33
|
+
}
|
|
34
|
+
return { frontmatter: parsed, body };
|
|
35
|
+
}
|
|
36
|
+
export function stringifyFrontmatter({ frontmatter, body, }) {
|
|
37
|
+
const yamlText = YAML.stringify(frontmatter, null, {
|
|
38
|
+
lineWidth: 120,
|
|
39
|
+
}).trim();
|
|
40
|
+
return `---\n${yamlText}\n---\n\n${body.trim()}\n`;
|
|
41
|
+
}
|
|
42
|
+
export function splitSections({ body }) {
|
|
43
|
+
return body
|
|
44
|
+
.split(/\r?\n---\r?\n/g)
|
|
45
|
+
.map((part) => part.trim())
|
|
46
|
+
.filter((part) => part.length > 0);
|
|
47
|
+
}
|
|
48
|
+
export function extractStarterContent({ body }) {
|
|
49
|
+
const sections = splitSections({ body });
|
|
50
|
+
const firstSection = sections[0] || '';
|
|
51
|
+
const match = firstSection.match(/^\*\*.+?\*\* \(\d+\) - .+?(?: \(edited .+?\))?\r?\n\r?\n([\s\S]*)$/);
|
|
52
|
+
if (!match)
|
|
53
|
+
return body.trim();
|
|
54
|
+
return (match[1] || '').trim();
|
|
55
|
+
}
|
|
56
|
+
export function buildMessageSections({ messages, }) {
|
|
57
|
+
return messages.map((message) => {
|
|
58
|
+
const attachmentLines = Array.from(message.attachments.values()).map((attachment) => `Attachment: ${attachment.url}`);
|
|
59
|
+
const contentParts = [];
|
|
60
|
+
const trimmedContent = message.content.trim();
|
|
61
|
+
if (trimmedContent) {
|
|
62
|
+
contentParts.push(trimmedContent);
|
|
63
|
+
}
|
|
64
|
+
if (attachmentLines.length > 0) {
|
|
65
|
+
contentParts.push(attachmentLines.join('\n'));
|
|
66
|
+
}
|
|
67
|
+
const content = contentParts.length > 0
|
|
68
|
+
? contentParts.join('\n\n')
|
|
69
|
+
: '_(no text content)_';
|
|
70
|
+
return {
|
|
71
|
+
messageId: message.id,
|
|
72
|
+
authorName: message.author.username,
|
|
73
|
+
authorId: message.author.id,
|
|
74
|
+
createdAt: new Date(message.createdTimestamp).toISOString(),
|
|
75
|
+
editedAt: message.editedTimestamp
|
|
76
|
+
? new Date(message.editedTimestamp).toISOString()
|
|
77
|
+
: null,
|
|
78
|
+
content,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function formatMessageSection({ section, }) {
|
|
83
|
+
const editedSuffix = section.editedAt ? ` (edited ${section.editedAt})` : '';
|
|
84
|
+
return `**${section.authorName}** (${section.authorId}) - ${section.createdAt}${editedSuffix}\n\n${section.content}`;
|
|
85
|
+
}
|
|
86
|
+
// Channel mention footer stored in the Discord starter message so
|
|
87
|
+
// projectChannelId survives a full re-sync from Discord (no local files).
|
|
88
|
+
// Uses <#id> so Discord renders it as a clickable channel link.
|
|
89
|
+
// Matches at start-of-string or after a newline so it works even when the
|
|
90
|
+
// footer is the only content in the message (e.g. empty body).
|
|
91
|
+
const PROJECT_CHANNEL_FOOTER_RE = /(?:^|\n)channel: <#(\d{17,20})>\s*$/;
|
|
92
|
+
const MAX_STARTER_MESSAGE_LENGTH = 2_000;
|
|
93
|
+
/** Append a channel mention footer, truncating the body so the total
|
|
94
|
+
* never exceeds Discord's 2000-char starter message limit. */
|
|
95
|
+
export function appendProjectChannelFooter({ content, projectChannelId, }) {
|
|
96
|
+
if (!projectChannelId)
|
|
97
|
+
return content;
|
|
98
|
+
const footer = `\nchannel: <#${projectChannelId}>`;
|
|
99
|
+
const maxContentLength = MAX_STARTER_MESSAGE_LENGTH - footer.length;
|
|
100
|
+
const truncated = content.length > maxContentLength
|
|
101
|
+
? content.slice(0, maxContentLength)
|
|
102
|
+
: content;
|
|
103
|
+
return `${truncated}${footer}`;
|
|
104
|
+
}
|
|
105
|
+
export function extractProjectChannelFromContent({ content, }) {
|
|
106
|
+
const match = content.match(PROJECT_CHANNEL_FOOTER_RE);
|
|
107
|
+
if (!match)
|
|
108
|
+
return { cleanContent: content };
|
|
109
|
+
return {
|
|
110
|
+
cleanContent: content.replace(PROJECT_CHANNEL_FOOTER_RE, '').trim(),
|
|
111
|
+
projectChannelId: match[1],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// Filesystem -> Discord sync.
|
|
2
|
+
// Reads markdown files and creates/updates/deletes forum threads to match.
|
|
3
|
+
// Handles upsert logic: new files create threads, existing files update them.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { MessageFlags } from 'discord.js';
|
|
7
|
+
import { createLogger } from '../logger.js';
|
|
8
|
+
import { appendProjectChannelFooter, extractStarterContent, getStringValue, parseFrontmatter, toStringArray, } from './markdown.js';
|
|
9
|
+
import { resolveForumChannel } from './discord-operations.js';
|
|
10
|
+
import { syncSingleThreadToFile } from './sync-to-files.js';
|
|
11
|
+
import { ForumSyncOperationError, shouldIgnorePath, } from './types.js';
|
|
12
|
+
const forumLogger = createLogger('FORUM');
|
|
13
|
+
// Fields managed by forum sync that should not be set by external writers (e.g. AI model).
|
|
14
|
+
// If a file has never been synced (no lastSyncedAt), these fields are stripped to prevent
|
|
15
|
+
// model-invented values from causing sync errors (e.g. fake threadId -> fetch fails,
|
|
16
|
+
// future lastSyncedAt -> file permanently skipped).
|
|
17
|
+
const SYSTEM_MANAGED_FIELDS = [
|
|
18
|
+
'threadId',
|
|
19
|
+
'forumChannelId',
|
|
20
|
+
'lastSyncedAt',
|
|
21
|
+
'lastMessageId',
|
|
22
|
+
'messageCount',
|
|
23
|
+
'author',
|
|
24
|
+
'authorId',
|
|
25
|
+
'createdAt',
|
|
26
|
+
'lastUpdated',
|
|
27
|
+
'project',
|
|
28
|
+
'projectChannelId',
|
|
29
|
+
];
|
|
30
|
+
/** Check that a value is a valid ISO date string that isn't in the future. */
|
|
31
|
+
function isValidPastIsoDate({ value }) {
|
|
32
|
+
if (typeof value !== 'string')
|
|
33
|
+
return false;
|
|
34
|
+
const parsed = Date.parse(value);
|
|
35
|
+
if (!Number.isFinite(parsed))
|
|
36
|
+
return false;
|
|
37
|
+
return parsed <= Date.now();
|
|
38
|
+
}
|
|
39
|
+
function stripSystemFieldsFromUnsyncedFile({ frontmatter, }) {
|
|
40
|
+
if (isValidPastIsoDate({ value: frontmatter.lastSyncedAt }))
|
|
41
|
+
return frontmatter;
|
|
42
|
+
const cleaned = { ...frontmatter };
|
|
43
|
+
for (const field of SYSTEM_MANAGED_FIELDS) {
|
|
44
|
+
delete cleaned[field];
|
|
45
|
+
}
|
|
46
|
+
return cleaned;
|
|
47
|
+
}
|
|
48
|
+
function isValidDiscordSnowflake({ value }) {
|
|
49
|
+
return /^\d{17,20}$/.test(value);
|
|
50
|
+
}
|
|
51
|
+
async function collectMarkdownEntries({ dir, outputDir, }) {
|
|
52
|
+
const exists = await fs.promises
|
|
53
|
+
.access(dir)
|
|
54
|
+
.then(() => true)
|
|
55
|
+
.catch(() => false);
|
|
56
|
+
if (!exists)
|
|
57
|
+
return [];
|
|
58
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
59
|
+
const relativeSub = path.relative(outputDir, dir);
|
|
60
|
+
const subfolder = relativeSub && relativeSub !== '.' ? relativeSub : undefined;
|
|
61
|
+
const markdownFiles = entries
|
|
62
|
+
.filter((entry) => {
|
|
63
|
+
return entry.isFile() && entry.name.endsWith('.md');
|
|
64
|
+
})
|
|
65
|
+
.map((entry) => {
|
|
66
|
+
return { filePath: path.join(dir, entry.name), subfolder };
|
|
67
|
+
});
|
|
68
|
+
const nestedEntries = await Promise.all(entries
|
|
69
|
+
.filter((entry) => {
|
|
70
|
+
return entry.isDirectory();
|
|
71
|
+
})
|
|
72
|
+
.map(async (entry) => {
|
|
73
|
+
return await collectMarkdownEntries({
|
|
74
|
+
dir: path.join(dir, entry.name),
|
|
75
|
+
outputDir,
|
|
76
|
+
});
|
|
77
|
+
}));
|
|
78
|
+
return [...markdownFiles, ...nestedEntries.flat()];
|
|
79
|
+
}
|
|
80
|
+
function resolveTagIds({ forumChannel, tagNames, }) {
|
|
81
|
+
if (tagNames.length === 0)
|
|
82
|
+
return [];
|
|
83
|
+
const normalizedWanted = new Set(tagNames.map((tag) => tag.toLowerCase().trim()));
|
|
84
|
+
return forumChannel.availableTags
|
|
85
|
+
.filter((tag) => normalizedWanted.has(tag.name.toLowerCase().trim()))
|
|
86
|
+
.map((tag) => tag.id);
|
|
87
|
+
}
|
|
88
|
+
/** Ensure all requested tag names exist on the forum channel, creating any missing ones. */
|
|
89
|
+
async function ensureForumTags({ forumChannel, tagNames, }) {
|
|
90
|
+
if (tagNames.length === 0)
|
|
91
|
+
return;
|
|
92
|
+
const existingNames = new Set(forumChannel.availableTags.map((tag) => tag.name.toLowerCase().trim()));
|
|
93
|
+
const missing = tagNames.filter((name) => !existingNames.has(name.toLowerCase().trim()));
|
|
94
|
+
if (missing.length === 0)
|
|
95
|
+
return;
|
|
96
|
+
// Discord forums allow up to 20 tags
|
|
97
|
+
const available = forumChannel.availableTags;
|
|
98
|
+
if (available.length + missing.length > 20)
|
|
99
|
+
return;
|
|
100
|
+
await forumChannel
|
|
101
|
+
.setAvailableTags([...available, ...missing.map((name) => ({ name }))], `Auto-create tags: ${missing.join(', ')}`)
|
|
102
|
+
.catch((cause) => {
|
|
103
|
+
forumLogger.warn(`Failed to create forum tags [${missing.join(', ')}]: ${cause instanceof Error ? cause.message : cause}`);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function hasTagName({ tags, tagName }) {
|
|
107
|
+
return tags.some((tag) => tag.toLowerCase().trim() === tagName.toLowerCase().trim());
|
|
108
|
+
}
|
|
109
|
+
async function upsertThreadFromFile({ discordClient, forumChannel, filePath, runtimeState, subfolder, project, projectChannelId, }) {
|
|
110
|
+
if (!fs.existsSync(filePath))
|
|
111
|
+
return 'skipped';
|
|
112
|
+
const content = await fs.promises
|
|
113
|
+
.readFile(filePath, 'utf8')
|
|
114
|
+
.catch((cause) => {
|
|
115
|
+
return new ForumSyncOperationError({
|
|
116
|
+
forumChannelId: forumChannel.id,
|
|
117
|
+
reason: `failed to read ${filePath}`,
|
|
118
|
+
cause,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
if (content instanceof Error)
|
|
122
|
+
return content;
|
|
123
|
+
const parsed = parseFrontmatter({ markdown: content });
|
|
124
|
+
const frontmatter = stripSystemFieldsFromUnsyncedFile({
|
|
125
|
+
frontmatter: parsed.frontmatter,
|
|
126
|
+
});
|
|
127
|
+
const rawThreadId = getStringValue({ value: frontmatter.threadId });
|
|
128
|
+
const threadId = rawThreadId && isValidDiscordSnowflake({ value: rawThreadId })
|
|
129
|
+
? rawThreadId
|
|
130
|
+
: '';
|
|
131
|
+
const title = getStringValue({ value: frontmatter.title }) ||
|
|
132
|
+
path.basename(filePath, '.md');
|
|
133
|
+
const tags = toStringArray({ value: frontmatter.tags });
|
|
134
|
+
const normalizedSubfolder = subfolder?.replaceAll('\\', '/').toLowerCase();
|
|
135
|
+
const isGlobalSubfolder = Boolean(normalizedSubfolder &&
|
|
136
|
+
(normalizedSubfolder === 'global' ||
|
|
137
|
+
normalizedSubfolder.startsWith('global/')));
|
|
138
|
+
const tagsWithScope = isGlobalSubfolder && !hasTagName({ tags, tagName: 'global' })
|
|
139
|
+
? [...tags, 'global']
|
|
140
|
+
: tags;
|
|
141
|
+
// Add project name as a forum tag if derived from subfolder
|
|
142
|
+
const allTags = project && !hasTagName({ tags: tagsWithScope, tagName: project })
|
|
143
|
+
? [...tagsWithScope, project]
|
|
144
|
+
: tagsWithScope;
|
|
145
|
+
const starterContent = extractStarterContent({ body: parsed.body });
|
|
146
|
+
// Resolve fallback BEFORE appending footer so an empty body doesn't
|
|
147
|
+
// produce a message that is just the channel footer.
|
|
148
|
+
const baseContent = starterContent || title || 'Untitled post';
|
|
149
|
+
const safeStarterContent = appendProjectChannelFooter({
|
|
150
|
+
content: baseContent,
|
|
151
|
+
projectChannelId,
|
|
152
|
+
});
|
|
153
|
+
const stat = await fs.promises.stat(filePath).catch((cause) => {
|
|
154
|
+
return new ForumSyncOperationError({
|
|
155
|
+
forumChannelId: forumChannel.id,
|
|
156
|
+
reason: `failed to stat ${filePath}`,
|
|
157
|
+
cause,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
if (stat instanceof Error)
|
|
161
|
+
return stat;
|
|
162
|
+
// Skip if file hasn't been modified since last sync
|
|
163
|
+
const lastSyncedAt = Date.parse(getStringValue({ value: frontmatter.lastSyncedAt }));
|
|
164
|
+
if (Number.isFinite(lastSyncedAt) && stat.mtimeMs <= lastSyncedAt)
|
|
165
|
+
return 'skipped';
|
|
166
|
+
await ensureForumTags({ forumChannel, tagNames: allTags });
|
|
167
|
+
const tagIds = resolveTagIds({ forumChannel, tagNames: allTags });
|
|
168
|
+
// No threadId in frontmatter -> create a new thread
|
|
169
|
+
if (!threadId) {
|
|
170
|
+
return await createNewThread({
|
|
171
|
+
forumChannel,
|
|
172
|
+
filePath,
|
|
173
|
+
title,
|
|
174
|
+
safeStarterContent,
|
|
175
|
+
tagIds,
|
|
176
|
+
runtimeState,
|
|
177
|
+
subfolder,
|
|
178
|
+
project,
|
|
179
|
+
projectChannelId,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Thread exists -> update it
|
|
183
|
+
return await updateExistingThread({
|
|
184
|
+
discordClient,
|
|
185
|
+
forumChannel,
|
|
186
|
+
filePath,
|
|
187
|
+
threadId,
|
|
188
|
+
title,
|
|
189
|
+
safeStarterContent,
|
|
190
|
+
tagIds,
|
|
191
|
+
runtimeState,
|
|
192
|
+
subfolder,
|
|
193
|
+
project,
|
|
194
|
+
projectChannelId,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async function createNewThread({ forumChannel, filePath, title, safeStarterContent, tagIds, runtimeState, subfolder, project, projectChannelId, }) {
|
|
198
|
+
const created = await forumChannel.threads
|
|
199
|
+
.create({
|
|
200
|
+
name: title.slice(0, 100) || 'Untitled post',
|
|
201
|
+
message: {
|
|
202
|
+
content: safeStarterContent.slice(0, 2_000),
|
|
203
|
+
flags: MessageFlags.SuppressEmbeds,
|
|
204
|
+
},
|
|
205
|
+
appliedTags: tagIds,
|
|
206
|
+
})
|
|
207
|
+
.catch((cause) => new ForumSyncOperationError({
|
|
208
|
+
forumChannelId: forumChannel.id,
|
|
209
|
+
reason: `failed creating thread from ${filePath}`,
|
|
210
|
+
cause,
|
|
211
|
+
}));
|
|
212
|
+
if (created instanceof Error)
|
|
213
|
+
return created;
|
|
214
|
+
// Re-sync the file to get the new threadId in frontmatter.
|
|
215
|
+
// outputDir is path.dirname(filePath) which already includes the subfolder,
|
|
216
|
+
// so we don't pass subfolder again to avoid double-nesting.
|
|
217
|
+
const syncResult = await syncSingleThreadToFile({
|
|
218
|
+
thread: created,
|
|
219
|
+
forumChannel,
|
|
220
|
+
outputDir: path.dirname(filePath),
|
|
221
|
+
runtimeState,
|
|
222
|
+
previousFilePath: filePath,
|
|
223
|
+
project,
|
|
224
|
+
projectChannelId,
|
|
225
|
+
});
|
|
226
|
+
if (syncResult instanceof Error)
|
|
227
|
+
return syncResult;
|
|
228
|
+
return 'created';
|
|
229
|
+
}
|
|
230
|
+
async function updateExistingThread({ discordClient, forumChannel, filePath, threadId, title, safeStarterContent, tagIds, runtimeState, subfolder, project, projectChannelId, }) {
|
|
231
|
+
const fetchedChannel = await discordClient.channels.fetch(threadId).catch((cause) => new ForumSyncOperationError({
|
|
232
|
+
forumChannelId: forumChannel.id,
|
|
233
|
+
reason: `failed fetching thread ${threadId}`,
|
|
234
|
+
cause,
|
|
235
|
+
}));
|
|
236
|
+
if (fetchedChannel instanceof Error)
|
|
237
|
+
return fetchedChannel;
|
|
238
|
+
if (!fetchedChannel ||
|
|
239
|
+
!fetchedChannel.isThread() ||
|
|
240
|
+
fetchedChannel.parentId !== forumChannel.id) {
|
|
241
|
+
return new ForumSyncOperationError({
|
|
242
|
+
forumChannelId: forumChannel.id,
|
|
243
|
+
reason: `thread ${threadId} not found in forum`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const updateResult = await fetchedChannel
|
|
247
|
+
.edit({
|
|
248
|
+
name: title.slice(0, 100) || fetchedChannel.name,
|
|
249
|
+
appliedTags: tagIds,
|
|
250
|
+
})
|
|
251
|
+
.catch((cause) => new ForumSyncOperationError({
|
|
252
|
+
forumChannelId: forumChannel.id,
|
|
253
|
+
reason: `failed editing thread ${threadId}`,
|
|
254
|
+
cause,
|
|
255
|
+
}));
|
|
256
|
+
if (updateResult instanceof Error)
|
|
257
|
+
return updateResult;
|
|
258
|
+
const starterMessage = await fetchedChannel
|
|
259
|
+
.fetchStarterMessage()
|
|
260
|
+
.catch((cause) => {
|
|
261
|
+
return new ForumSyncOperationError({
|
|
262
|
+
forumChannelId: forumChannel.id,
|
|
263
|
+
reason: `failed fetching starter message for ${threadId}`,
|
|
264
|
+
cause,
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
if (starterMessage instanceof Error)
|
|
268
|
+
return starterMessage;
|
|
269
|
+
if (starterMessage && starterMessage.content !== safeStarterContent) {
|
|
270
|
+
const editResult = await starterMessage
|
|
271
|
+
.edit({
|
|
272
|
+
content: safeStarterContent.slice(0, 2_000),
|
|
273
|
+
flags: MessageFlags.SuppressEmbeds,
|
|
274
|
+
})
|
|
275
|
+
.catch((cause) => new ForumSyncOperationError({
|
|
276
|
+
forumChannelId: forumChannel.id,
|
|
277
|
+
reason: `failed editing starter message for ${threadId}`,
|
|
278
|
+
cause,
|
|
279
|
+
}));
|
|
280
|
+
if (editResult instanceof Error)
|
|
281
|
+
return editResult;
|
|
282
|
+
}
|
|
283
|
+
// Re-sync the file to update frontmatter with latest state.
|
|
284
|
+
// outputDir is path.dirname(filePath) which already includes the subfolder.
|
|
285
|
+
const syncResult = await syncSingleThreadToFile({
|
|
286
|
+
thread: fetchedChannel,
|
|
287
|
+
forumChannel,
|
|
288
|
+
outputDir: path.dirname(filePath),
|
|
289
|
+
runtimeState,
|
|
290
|
+
project,
|
|
291
|
+
projectChannelId,
|
|
292
|
+
});
|
|
293
|
+
if (syncResult instanceof Error)
|
|
294
|
+
return syncResult;
|
|
295
|
+
return 'updated';
|
|
296
|
+
}
|
|
297
|
+
async function deleteThreadFromFilePath({ discordClient, forumChannel, filePath, }) {
|
|
298
|
+
const filename = path.basename(filePath, '.md');
|
|
299
|
+
if (!/^\d+$/.test(filename))
|
|
300
|
+
return;
|
|
301
|
+
const threadId = filename;
|
|
302
|
+
const fetchedChannel = await discordClient.channels.fetch(threadId).catch((cause) => new ForumSyncOperationError({
|
|
303
|
+
forumChannelId: forumChannel.id,
|
|
304
|
+
reason: `failed fetching deleted thread ${threadId}`,
|
|
305
|
+
cause,
|
|
306
|
+
}));
|
|
307
|
+
if (fetchedChannel instanceof Error)
|
|
308
|
+
return fetchedChannel;
|
|
309
|
+
if (!fetchedChannel ||
|
|
310
|
+
!fetchedChannel.isThread() ||
|
|
311
|
+
fetchedChannel.parentId !== forumChannel.id) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const deleteResult = await fetchedChannel
|
|
315
|
+
.delete('Deleted from forum sync markdown directory')
|
|
316
|
+
.catch((cause) => new ForumSyncOperationError({
|
|
317
|
+
forumChannelId: forumChannel.id,
|
|
318
|
+
reason: `failed deleting thread ${threadId}`,
|
|
319
|
+
cause,
|
|
320
|
+
}));
|
|
321
|
+
if (deleteResult instanceof Error)
|
|
322
|
+
return deleteResult;
|
|
323
|
+
}
|
|
324
|
+
export async function syncFilesToForum({ discordClient, forumChannelId, outputDir, runtimeState, changedFilePaths, deletedFilePaths, }) {
|
|
325
|
+
const forumChannel = await resolveForumChannel({
|
|
326
|
+
discordClient,
|
|
327
|
+
forumChannelId,
|
|
328
|
+
});
|
|
329
|
+
if (forumChannel instanceof Error)
|
|
330
|
+
return forumChannel;
|
|
331
|
+
// When changedFilePaths is provided (from file watcher), derive subfolder from path.
|
|
332
|
+
// Otherwise, recursively scan all markdown files in outputDir.
|
|
333
|
+
const changedEntries = changedFilePaths
|
|
334
|
+
? changedFilePaths.map((filePath) => {
|
|
335
|
+
const rel = path.relative(outputDir, path.dirname(filePath));
|
|
336
|
+
const subfolder = rel && rel !== '.' ? rel : undefined;
|
|
337
|
+
return { filePath, subfolder };
|
|
338
|
+
})
|
|
339
|
+
: await collectMarkdownEntries({ dir: outputDir, outputDir });
|
|
340
|
+
// Resolve channel names for subfolders (each subfolder name is a Discord channel ID).
|
|
341
|
+
// Cache resolutions to avoid redundant API calls.
|
|
342
|
+
const channelNameCache = new Map();
|
|
343
|
+
const resolveChannelName = async (channelId) => {
|
|
344
|
+
if (channelNameCache.has(channelId))
|
|
345
|
+
return channelNameCache.get(channelId);
|
|
346
|
+
const channel = await discordClient.channels
|
|
347
|
+
.fetch(channelId)
|
|
348
|
+
.catch(() => null);
|
|
349
|
+
const name = channel && 'name' in channel && typeof channel.name === 'string'
|
|
350
|
+
? channel.name
|
|
351
|
+
: null;
|
|
352
|
+
channelNameCache.set(channelId, name);
|
|
353
|
+
return name;
|
|
354
|
+
};
|
|
355
|
+
const result = {
|
|
356
|
+
created: 0,
|
|
357
|
+
updated: 0,
|
|
358
|
+
skipped: 0,
|
|
359
|
+
deleted: 0,
|
|
360
|
+
};
|
|
361
|
+
for (const { filePath, subfolder } of changedEntries) {
|
|
362
|
+
if (!filePath.endsWith('.md'))
|
|
363
|
+
continue;
|
|
364
|
+
if (runtimeState && shouldIgnorePath({ runtimeState, filePath })) {
|
|
365
|
+
result.skipped += 1;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
// Derive project info from subfolder (subfolder name is the channel ID).
|
|
369
|
+
// Only use subfolder as channelId if it looks like a valid Discord snowflake
|
|
370
|
+
// to prevent nested paths or arbitrary folder names from being treated as IDs.
|
|
371
|
+
const projectChannelId = subfolder && isValidDiscordSnowflake({ value: subfolder })
|
|
372
|
+
? subfolder
|
|
373
|
+
: undefined;
|
|
374
|
+
const project = projectChannelId
|
|
375
|
+
? (await resolveChannelName(projectChannelId)) || undefined
|
|
376
|
+
: undefined;
|
|
377
|
+
const upsertResult = await upsertThreadFromFile({
|
|
378
|
+
discordClient,
|
|
379
|
+
forumChannel,
|
|
380
|
+
filePath,
|
|
381
|
+
runtimeState,
|
|
382
|
+
subfolder,
|
|
383
|
+
project,
|
|
384
|
+
projectChannelId,
|
|
385
|
+
});
|
|
386
|
+
// Keep syncing other files even if one file has stale/bad metadata
|
|
387
|
+
// (e.g. threadId that no longer exists). A single bad file should not
|
|
388
|
+
// block watcher startup for the whole memory directory.
|
|
389
|
+
if (upsertResult instanceof Error) {
|
|
390
|
+
forumLogger.warn(`Skipping ${filePath}: ${upsertResult.message}`);
|
|
391
|
+
result.skipped += 1;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (upsertResult === 'created') {
|
|
395
|
+
result.created += 1;
|
|
396
|
+
}
|
|
397
|
+
else if (upsertResult === 'updated') {
|
|
398
|
+
result.updated += 1;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
result.skipped += 1;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
for (const filePath of deletedFilePaths || []) {
|
|
405
|
+
const deleteResult = await deleteThreadFromFilePath({
|
|
406
|
+
discordClient,
|
|
407
|
+
forumChannel,
|
|
408
|
+
filePath,
|
|
409
|
+
});
|
|
410
|
+
if (deleteResult instanceof Error) {
|
|
411
|
+
forumLogger.warn(`Skipping delete ${filePath}: ${deleteResult.message}`);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
result.deleted += 1;
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
}
|