@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,685 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import {
|
|
3
|
+
ChannelType,
|
|
4
|
+
ThreadAutoArchiveDuration,
|
|
5
|
+
type Client,
|
|
6
|
+
type TextChannel,
|
|
7
|
+
type ThreadChannel,
|
|
8
|
+
} from 'discord.js'
|
|
9
|
+
import type {
|
|
10
|
+
OpencodeClient,
|
|
11
|
+
Part,
|
|
12
|
+
} from '@opencode-ai/sdk/v2'
|
|
13
|
+
import {
|
|
14
|
+
getChannelVerbosity,
|
|
15
|
+
getPartMessageIds,
|
|
16
|
+
getThreadIdBySessionId,
|
|
17
|
+
getThreadSessionSource,
|
|
18
|
+
listTrackedTextChannels,
|
|
19
|
+
setPartMessagesBatch,
|
|
20
|
+
upsertThreadSession,
|
|
21
|
+
} from './database.js'
|
|
22
|
+
import { sendThreadMessage } from './discord-utils.js'
|
|
23
|
+
import { createLogger, LogPrefix } from './logger.js'
|
|
24
|
+
import {
|
|
25
|
+
formatPart,
|
|
26
|
+
collectSessionChunks,
|
|
27
|
+
batchChunksForDiscord,
|
|
28
|
+
type SessionChunk,
|
|
29
|
+
} from './message-formatting.js'
|
|
30
|
+
import {
|
|
31
|
+
initializeOpencodeForDirectory,
|
|
32
|
+
} from './opencode.js'
|
|
33
|
+
import { isEssentialToolPart } from './session-handler/thread-session-runtime.js'
|
|
34
|
+
import { notifyError } from './sentry.js'
|
|
35
|
+
import { extractNonXmlContent } from './xml.js'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
const logger = createLogger(LogPrefix.OPENCODE)
|
|
39
|
+
|
|
40
|
+
const EXTERNAL_SYNC_INTERVAL_MS = 5_000
|
|
41
|
+
// Don't sync sessions from before the CLI started. 5 min grace window
|
|
42
|
+
// covers sessions that were just created before the bot connected.
|
|
43
|
+
const CLI_START_MS = Date.now() - 5 * 60 * 1000
|
|
44
|
+
|
|
45
|
+
type RenderableUserTextPart = {
|
|
46
|
+
id: string
|
|
47
|
+
text: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type SessionMessagesResponse = Awaited<
|
|
51
|
+
ReturnType<OpencodeClient['session']['messages']>
|
|
52
|
+
>
|
|
53
|
+
type SessionMessage = NonNullable<SessionMessagesResponse['data']>[number]
|
|
54
|
+
type SessionMessageLike = {
|
|
55
|
+
info: {
|
|
56
|
+
role: string
|
|
57
|
+
}
|
|
58
|
+
parts: Part[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type DiscordOriginMetadata = {
|
|
62
|
+
messageId?: string
|
|
63
|
+
username: string
|
|
64
|
+
threadId?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type TrackedTextChannelRow = Awaited<ReturnType<typeof listTrackedTextChannels>>[number]
|
|
68
|
+
|
|
69
|
+
type DirectorySyncTarget = {
|
|
70
|
+
directory: string
|
|
71
|
+
channelId: string
|
|
72
|
+
startMs: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let externalSyncInterval: ReturnType<typeof setInterval> | null = null
|
|
76
|
+
|
|
77
|
+
function isSyntheticTextPart(part: Extract<Part, { type: 'text' }>): boolean {
|
|
78
|
+
const candidate = part as Extract<Part, { type: 'text' }> & {
|
|
79
|
+
synthetic?: unknown
|
|
80
|
+
}
|
|
81
|
+
return candidate.synthetic === true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseDiscordOriginMetadata(text: string): DiscordOriginMetadata | null {
|
|
85
|
+
const match = text.match(/<discord-user\s+([^>]+)\s*\/>/)
|
|
86
|
+
if (!match?.[1]) {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
const attrs = [...match[1].matchAll(/([a-z-]+)="([^"]*)"/g)].reduce(
|
|
90
|
+
(acc, current) => {
|
|
91
|
+
const [, key, value] = current
|
|
92
|
+
if (!key) {
|
|
93
|
+
return acc
|
|
94
|
+
}
|
|
95
|
+
acc[key] = value || ''
|
|
96
|
+
return acc
|
|
97
|
+
},
|
|
98
|
+
{} as Record<string, string>,
|
|
99
|
+
)
|
|
100
|
+
const username = attrs['name']
|
|
101
|
+
if (!username) {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
messageId: attrs['message-id'] || undefined,
|
|
106
|
+
username,
|
|
107
|
+
threadId: attrs['thread-id'] || undefined,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getDiscordOriginMetadataFromMessage({
|
|
112
|
+
message,
|
|
113
|
+
}: {
|
|
114
|
+
message: SessionMessageLike
|
|
115
|
+
}): DiscordOriginMetadata | null {
|
|
116
|
+
const textParts = message.parts.filter((p): p is Extract<typeof p, { type: 'text' }> => {
|
|
117
|
+
return p.type === 'text'
|
|
118
|
+
})
|
|
119
|
+
// Synthetic parts first (normal promptAsync path), then non-synthetic
|
|
120
|
+
// (session.command() path where the tag is embedded in arguments text).
|
|
121
|
+
const sorted = [
|
|
122
|
+
...textParts.filter((p) => { return isSyntheticTextPart(p) }),
|
|
123
|
+
...textParts.filter((p) => { return !isSyntheticTextPart(p) }),
|
|
124
|
+
]
|
|
125
|
+
for (const part of sorted) {
|
|
126
|
+
const metadata = parseDiscordOriginMetadata(part.text || '')
|
|
127
|
+
if (metadata) {
|
|
128
|
+
return metadata
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getRenderableUserTextParts({
|
|
135
|
+
message,
|
|
136
|
+
}: {
|
|
137
|
+
message: SessionMessageLike
|
|
138
|
+
}): RenderableUserTextPart[] {
|
|
139
|
+
if (message.info.role !== 'user') {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return message.parts.flatMap((part) => {
|
|
144
|
+
if (part.type !== 'text') {
|
|
145
|
+
return [] as RenderableUserTextPart[]
|
|
146
|
+
}
|
|
147
|
+
if (isSyntheticTextPart(part)) {
|
|
148
|
+
return [] as RenderableUserTextPart[]
|
|
149
|
+
}
|
|
150
|
+
const cleanedText = extractNonXmlContent(part.text || '').trim()
|
|
151
|
+
if (!cleanedText) {
|
|
152
|
+
return [] as RenderableUserTextPart[]
|
|
153
|
+
}
|
|
154
|
+
return [{ id: part.id, text: cleanedText }]
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getExternalUserMirrorText({
|
|
159
|
+
username,
|
|
160
|
+
prompt,
|
|
161
|
+
}: {
|
|
162
|
+
username: string
|
|
163
|
+
prompt: string
|
|
164
|
+
}): string {
|
|
165
|
+
return `» **${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Pure derivation: is the latest user turn from Discord?
|
|
169
|
+
// Checks the newest user message with renderable text for a <discord-user />
|
|
170
|
+
// synthetic part. If present, the session is currently driven from Discord
|
|
171
|
+
// (kimaki manages it) and external sync should skip it. If absent (CLI/TUI),
|
|
172
|
+
// external sync should mirror it — this naturally handles the "reclaim" case
|
|
173
|
+
// (external → discord → external) without any DB source toggling.
|
|
174
|
+
function isLatestUserTurnFromDiscord({
|
|
175
|
+
messages,
|
|
176
|
+
}: {
|
|
177
|
+
messages: SessionMessageLike[]
|
|
178
|
+
}): boolean {
|
|
179
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
180
|
+
const message = messages[i]!
|
|
181
|
+
if (message.info.role !== 'user') {
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const renderableParts = getRenderableUserTextParts({ message })
|
|
185
|
+
if (renderableParts.length === 0) {
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
// Found the latest user message with actual text content.
|
|
189
|
+
// If it has <discord-user /> origin metadata, it came from Discord.
|
|
190
|
+
return getDiscordOriginMetadataFromMessage({ message }) !== null
|
|
191
|
+
}
|
|
192
|
+
// No user messages with text — treat as external (allow sync).
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function shouldMirrorAssistantPart({
|
|
197
|
+
part,
|
|
198
|
+
verbosity,
|
|
199
|
+
}: {
|
|
200
|
+
part: Part
|
|
201
|
+
verbosity: 'tools_and_text' | 'text_and_essential_tools' | 'text_only'
|
|
202
|
+
}): boolean {
|
|
203
|
+
if (verbosity === 'text_only') {
|
|
204
|
+
return part.type === 'text'
|
|
205
|
+
}
|
|
206
|
+
if (verbosity === 'text_and_essential_tools') {
|
|
207
|
+
if (part.type === 'text') {
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
return isEssentialToolPart(part)
|
|
211
|
+
}
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getSessionThreadName({
|
|
216
|
+
sessionTitle,
|
|
217
|
+
messages,
|
|
218
|
+
}: {
|
|
219
|
+
sessionTitle?: string | null
|
|
220
|
+
messages: SessionMessageLike[]
|
|
221
|
+
}): string {
|
|
222
|
+
const normalizedTitle = sessionTitle?.trim()
|
|
223
|
+
if (normalizedTitle) {
|
|
224
|
+
return normalizedTitle.slice(0, 100)
|
|
225
|
+
}
|
|
226
|
+
const firstUserMessage = messages.find((message) => {
|
|
227
|
+
return message.info.role === 'user'
|
|
228
|
+
})
|
|
229
|
+
const firstUserText = firstUserMessage
|
|
230
|
+
? getRenderableUserTextParts({ message: firstUserMessage })
|
|
231
|
+
.map((part) => {
|
|
232
|
+
return part.text
|
|
233
|
+
})
|
|
234
|
+
.join(' ')
|
|
235
|
+
.trim()
|
|
236
|
+
: ''
|
|
237
|
+
if (firstUserText) {
|
|
238
|
+
return firstUserText.slice(0, 100)
|
|
239
|
+
}
|
|
240
|
+
return 'opencode session'
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
type SessionWithTime = { time: { created: number; updated: number } }
|
|
244
|
+
|
|
245
|
+
function getSessionRecencyTimestamp(session: SessionWithTime): number {
|
|
246
|
+
return session.time.updated || session.time.created || 0
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function sortSessionsByRecency<T extends SessionWithTime>(sessions: T[]): T[] {
|
|
250
|
+
return [...sessions].sort((left, right) => {
|
|
251
|
+
return getSessionRecencyTimestamp(right) - getSessionRecencyTimestamp(left)
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function groupTrackedChannelsByDirectory(
|
|
256
|
+
trackedChannels: TrackedTextChannelRow[],
|
|
257
|
+
): DirectorySyncTarget[] {
|
|
258
|
+
const grouped = trackedChannels.reduce((acc, channel) => {
|
|
259
|
+
const existing = acc.get(channel.directory)
|
|
260
|
+
const createdAtMs = Math.max(channel.created_at?.getTime() || 0, CLI_START_MS)
|
|
261
|
+
if (!existing) {
|
|
262
|
+
acc.set(channel.directory, {
|
|
263
|
+
directory: channel.directory,
|
|
264
|
+
channelId: channel.channel_id,
|
|
265
|
+
startMs: createdAtMs,
|
|
266
|
+
})
|
|
267
|
+
return acc
|
|
268
|
+
}
|
|
269
|
+
if (createdAtMs < existing.startMs) {
|
|
270
|
+
acc.set(channel.directory, {
|
|
271
|
+
directory: channel.directory,
|
|
272
|
+
channelId: channel.channel_id,
|
|
273
|
+
startMs: createdAtMs,
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
return acc
|
|
277
|
+
}, new Map<string, DirectorySyncTarget>())
|
|
278
|
+
return [...grouped.values()]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function ensureExternalSessionThread({
|
|
282
|
+
discordClient,
|
|
283
|
+
channelId,
|
|
284
|
+
sessionId,
|
|
285
|
+
sessionTitle,
|
|
286
|
+
messages,
|
|
287
|
+
}: {
|
|
288
|
+
discordClient: Client
|
|
289
|
+
channelId: string
|
|
290
|
+
sessionId: string
|
|
291
|
+
sessionTitle?: string | null
|
|
292
|
+
messages: SessionMessage[]
|
|
293
|
+
}): Promise<ThreadChannel | Error | null> {
|
|
294
|
+
const existingThreadId = await getThreadIdBySessionId(sessionId)
|
|
295
|
+
if (existingThreadId) {
|
|
296
|
+
// Caller already verified via isLatestUserTurnFromDiscord that this
|
|
297
|
+
// session should be synced. If the thread was kimaki-owned, flip it
|
|
298
|
+
// to external_poll so typing and future polls work naturally.
|
|
299
|
+
const existingSource = await getThreadSessionSource(existingThreadId)
|
|
300
|
+
if (existingSource === 'kimaki') {
|
|
301
|
+
await upsertThreadSession({
|
|
302
|
+
threadId: existingThreadId,
|
|
303
|
+
sessionId,
|
|
304
|
+
source: 'external_poll',
|
|
305
|
+
})
|
|
306
|
+
logger.log(`[EXTERNAL_SYNC] Reclaimed thread ${existingThreadId} for session ${sessionId} (user resumed from OpenCode)`)
|
|
307
|
+
}
|
|
308
|
+
const existingThread = await discordClient.channels.fetch(existingThreadId).catch((error) => {
|
|
309
|
+
return new Error(`Failed to fetch thread ${existingThreadId}`, {
|
|
310
|
+
cause: error,
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
if (!(existingThread instanceof Error) && existingThread?.isThread()) {
|
|
314
|
+
return existingThread
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const parentChannel = await discordClient.channels.fetch(channelId).catch((error) => {
|
|
319
|
+
return new Error(`Failed to fetch parent channel ${channelId}`, {
|
|
320
|
+
cause: error,
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
if (parentChannel instanceof Error) {
|
|
324
|
+
return parentChannel
|
|
325
|
+
}
|
|
326
|
+
if (!parentChannel || parentChannel.type !== ChannelType.GuildText) {
|
|
327
|
+
return new Error(`Channel ${channelId} is not a text channel`)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const threadName = 'Sync: ' + getSessionThreadName({ sessionTitle, messages })
|
|
331
|
+
const thread = await (parentChannel as TextChannel).threads.create({
|
|
332
|
+
name: threadName.slice(0, 100),
|
|
333
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
334
|
+
reason: `Sync external OpenCode session ${sessionId}`,
|
|
335
|
+
}).catch((error) => {
|
|
336
|
+
return new Error(`Failed to create thread for session ${sessionId}`, {
|
|
337
|
+
cause: error,
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
if (thread instanceof Error) {
|
|
341
|
+
return thread
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await upsertThreadSession({
|
|
345
|
+
threadId: thread.id,
|
|
346
|
+
sessionId,
|
|
347
|
+
source: 'external_poll',
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
return thread
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
type DirectPartMapping = { partId: string; messageId: string; threadId: string }
|
|
354
|
+
|
|
355
|
+
// Collect all unsynced parts from all messages into SessionChunks.
|
|
356
|
+
// User messages that originated from this Discord thread are returned as
|
|
357
|
+
// directMappings (persisted without sending a Discord message). All other
|
|
358
|
+
// user and assistant parts are returned as chunks to send.
|
|
359
|
+
function collectUnsyncedChunks({
|
|
360
|
+
messages,
|
|
361
|
+
syncedPartIds,
|
|
362
|
+
verbosity,
|
|
363
|
+
thread,
|
|
364
|
+
}: {
|
|
365
|
+
messages: SessionMessage[]
|
|
366
|
+
syncedPartIds: Set<string>
|
|
367
|
+
verbosity: 'tools_and_text' | 'text_and_essential_tools' | 'text_only'
|
|
368
|
+
thread: ThreadChannel
|
|
369
|
+
}): { chunks: SessionChunk[]; directMappings: DirectPartMapping[] } {
|
|
370
|
+
const chunks: SessionChunk[] = []
|
|
371
|
+
const directMappings: DirectPartMapping[] = []
|
|
372
|
+
|
|
373
|
+
for (const message of messages) {
|
|
374
|
+
if (message.info.role === 'user') {
|
|
375
|
+
const renderableParts = getRenderableUserTextParts({ message })
|
|
376
|
+
const unsyncedParts = renderableParts.filter((p) => {
|
|
377
|
+
return !syncedPartIds.has(p.id)
|
|
378
|
+
})
|
|
379
|
+
if (unsyncedParts.length === 0) {
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
// If the user message came from this Discord thread, skip mirroring
|
|
383
|
+
// — it's already visible. When message-id is available, record a
|
|
384
|
+
// direct mapping for part dedup. When it's missing (sourceMessageId
|
|
385
|
+
// is optional in IngressInput), just mark parts as synced.
|
|
386
|
+
const discordOrigin = getDiscordOriginMetadataFromMessage({ message })
|
|
387
|
+
if (discordOrigin && (!discordOrigin.threadId || discordOrigin.threadId === thread.id)) {
|
|
388
|
+
unsyncedParts.forEach((part) => {
|
|
389
|
+
directMappings.push({
|
|
390
|
+
partId: part.id,
|
|
391
|
+
messageId: discordOrigin.messageId || '',
|
|
392
|
+
threadId: thread.id,
|
|
393
|
+
})
|
|
394
|
+
syncedPartIds.add(part.id)
|
|
395
|
+
})
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
const promptText = unsyncedParts.map((p) => {
|
|
399
|
+
return p.text
|
|
400
|
+
}).join('\n\n')
|
|
401
|
+
chunks.push({
|
|
402
|
+
partIds: unsyncedParts.map((p) => {
|
|
403
|
+
return p.id
|
|
404
|
+
}),
|
|
405
|
+
content: getExternalUserMirrorText({ username: 'user', prompt: promptText }),
|
|
406
|
+
})
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (message.info.role !== 'assistant') {
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
// Filter assistant parts by verbosity before passing to shared collector
|
|
414
|
+
const filteredParts = message.parts.filter((part) => {
|
|
415
|
+
return shouldMirrorAssistantPart({ part, verbosity })
|
|
416
|
+
})
|
|
417
|
+
const { chunks: assistantChunks } = collectSessionChunks({
|
|
418
|
+
messages: [{ info: message.info, parts: filteredParts }],
|
|
419
|
+
skipPartIds: syncedPartIds,
|
|
420
|
+
})
|
|
421
|
+
// Mark empty-content parts as synced (collectSessionChunks skips them)
|
|
422
|
+
for (const part of filteredParts) {
|
|
423
|
+
if (!syncedPartIds.has(part.id)) {
|
|
424
|
+
const content = formatPart(part)
|
|
425
|
+
if (!content.trim()) {
|
|
426
|
+
syncedPartIds.add(part.id)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
chunks.push(...assistantChunks)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return { chunks, directMappings }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function syncSessionToThread({
|
|
437
|
+
client,
|
|
438
|
+
discordClient,
|
|
439
|
+
directory,
|
|
440
|
+
channelId,
|
|
441
|
+
sessionId,
|
|
442
|
+
sessionTitle,
|
|
443
|
+
}: {
|
|
444
|
+
client: OpencodeClient
|
|
445
|
+
discordClient: Client
|
|
446
|
+
directory: string
|
|
447
|
+
channelId: string
|
|
448
|
+
sessionId: string
|
|
449
|
+
sessionTitle?: string | null
|
|
450
|
+
}): Promise<void> {
|
|
451
|
+
const messagesResponse = await client.session.messages({
|
|
452
|
+
sessionID: sessionId,
|
|
453
|
+
directory,
|
|
454
|
+
}).catch((error) => {
|
|
455
|
+
return new Error(`Failed to fetch messages for session ${sessionId}`, {
|
|
456
|
+
cause: error,
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
if (messagesResponse instanceof Error) {
|
|
460
|
+
throw messagesResponse
|
|
461
|
+
}
|
|
462
|
+
const messages = messagesResponse.data || []
|
|
463
|
+
|
|
464
|
+
// Pure derivation from opencode events: if the latest user turn has
|
|
465
|
+
// <discord-user /> metadata, kimaki's thread runtime owns this session.
|
|
466
|
+
// Skip external sync entirely. When the user resumes from CLI/TUI the
|
|
467
|
+
// latest user turn will lack the tag, so sync picks it up naturally.
|
|
468
|
+
if (isLatestUserTurnFromDiscord({ messages })) {
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const thread = await ensureExternalSessionThread({
|
|
473
|
+
discordClient,
|
|
474
|
+
channelId,
|
|
475
|
+
sessionId,
|
|
476
|
+
sessionTitle,
|
|
477
|
+
messages,
|
|
478
|
+
})
|
|
479
|
+
if (thread === null) {
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
if (thread instanceof Error) {
|
|
483
|
+
throw thread
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const [existingPartIds, verbosity] = await Promise.all([
|
|
487
|
+
getPartMessageIds(thread.id),
|
|
488
|
+
getChannelVerbosity(thread.parentId || thread.id),
|
|
489
|
+
])
|
|
490
|
+
const syncedPartIds = new Set(existingPartIds)
|
|
491
|
+
|
|
492
|
+
const { chunks, directMappings } = collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread })
|
|
493
|
+
|
|
494
|
+
// Persist mappings for user parts that originated from this Discord thread
|
|
495
|
+
if (directMappings.length > 0) {
|
|
496
|
+
await setPartMessagesBatch(directMappings)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const batched = batchChunksForDiscord(chunks)
|
|
500
|
+
for (const batch of batched) {
|
|
501
|
+
const sentMessage = await sendThreadMessage(thread, batch.content)
|
|
502
|
+
await setPartMessagesBatch(
|
|
503
|
+
batch.partIds.map((partId) => ({
|
|
504
|
+
partId,
|
|
505
|
+
messageId: sentMessage.id,
|
|
506
|
+
threadId: thread.id,
|
|
507
|
+
})),
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Pulse typing indicator for sessions that are currently busy.
|
|
513
|
+
// Takes the global session statuses map (already fetched) and sends
|
|
514
|
+
// typing to threads whose session is busy and still managed by external_poll.
|
|
515
|
+
async function pulseTypingForBusySessions({
|
|
516
|
+
discordClient,
|
|
517
|
+
statuses,
|
|
518
|
+
}: {
|
|
519
|
+
discordClient: Client
|
|
520
|
+
statuses: Record<string, { type: string }>
|
|
521
|
+
}): Promise<void> {
|
|
522
|
+
for (const [sessionId, status] of Object.entries(statuses)) {
|
|
523
|
+
if (status.type !== 'busy') {
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
const threadId = await getThreadIdBySessionId(sessionId)
|
|
527
|
+
if (!threadId) {
|
|
528
|
+
continue
|
|
529
|
+
}
|
|
530
|
+
// Skip sessions already managed by the runtime (source='kimaki')
|
|
531
|
+
const source = await getThreadSessionSource(threadId)
|
|
532
|
+
if (source && source !== 'external_poll') {
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
const thread = await discordClient.channels.fetch(threadId).catch(() => {
|
|
536
|
+
return null
|
|
537
|
+
})
|
|
538
|
+
if (thread?.isThread()) {
|
|
539
|
+
await thread.sendTyping().catch(() => {})
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const EXTERNAL_SYNC_MAX_SESSIONS = 50
|
|
545
|
+
|
|
546
|
+
async function pollExternalSessions({
|
|
547
|
+
discordClient,
|
|
548
|
+
}: {
|
|
549
|
+
discordClient: Client
|
|
550
|
+
}): Promise<void> {
|
|
551
|
+
const trackedChannels = await listTrackedTextChannels()
|
|
552
|
+
const directoryTargets = groupTrackedChannelsByDirectory(trackedChannels)
|
|
553
|
+
.filter((t) => {
|
|
554
|
+
return fs.existsSync(t.directory)
|
|
555
|
+
})
|
|
556
|
+
if (directoryTargets.length === 0) {
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
for (const target of directoryTargets) {
|
|
561
|
+
const directory = target.directory
|
|
562
|
+
const channelId = target.channelId
|
|
563
|
+
const startMs = target.startMs
|
|
564
|
+
|
|
565
|
+
const clientResult = await initializeOpencodeForDirectory(directory, {
|
|
566
|
+
channelId,
|
|
567
|
+
})
|
|
568
|
+
if (clientResult instanceof Error) {
|
|
569
|
+
logger.warn(
|
|
570
|
+
`[EXTERNAL_SYNC] Failed to initialize OpenCode for ${directory}: ${clientResult.message}`,
|
|
571
|
+
)
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const client = clientResult()
|
|
576
|
+
const sessionsResponse = await client.session.list({
|
|
577
|
+
directory,
|
|
578
|
+
start: startMs,
|
|
579
|
+
limit: EXTERNAL_SYNC_MAX_SESSIONS,
|
|
580
|
+
}).catch((error) => {
|
|
581
|
+
return new Error(`Failed to list sessions for ${directory}`, {
|
|
582
|
+
cause: error,
|
|
583
|
+
})
|
|
584
|
+
})
|
|
585
|
+
if (sessionsResponse instanceof Error) {
|
|
586
|
+
logger.warn(`[EXTERNAL_SYNC] ${sessionsResponse.message}`)
|
|
587
|
+
continue
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const statusesResponse = await client.session.status({
|
|
591
|
+
directory,
|
|
592
|
+
}).catch(() => {
|
|
593
|
+
return null
|
|
594
|
+
})
|
|
595
|
+
if (statusesResponse?.data) {
|
|
596
|
+
await pulseTypingForBusySessions({
|
|
597
|
+
discordClient,
|
|
598
|
+
statuses: statusesResponse.data as Record<string, { type: string }>,
|
|
599
|
+
}).catch(() => {})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const sessions = (sessionsResponse.data || []).filter((session) => {
|
|
603
|
+
const title = session.title || ''
|
|
604
|
+
if (/^new session\s*-/i.test(title)) {
|
|
605
|
+
return false
|
|
606
|
+
}
|
|
607
|
+
return !/subagent\)\s*$/i.test(title)
|
|
608
|
+
})
|
|
609
|
+
const sorted = sortSessionsByRecency(sessions)
|
|
610
|
+
|
|
611
|
+
for (const session of sorted) {
|
|
612
|
+
await syncSessionToThread({
|
|
613
|
+
client,
|
|
614
|
+
discordClient,
|
|
615
|
+
directory,
|
|
616
|
+
channelId,
|
|
617
|
+
sessionId: session.id,
|
|
618
|
+
sessionTitle: session.title,
|
|
619
|
+
}).catch((error) => {
|
|
620
|
+
logger.warn(
|
|
621
|
+
`[EXTERNAL_SYNC] Failed syncing session ${session.id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
622
|
+
)
|
|
623
|
+
void notifyError(
|
|
624
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
625
|
+
`External session sync failed for ${session.id}`,
|
|
626
|
+
)
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function startExternalOpencodeSessionSync({
|
|
633
|
+
discordClient,
|
|
634
|
+
}: {
|
|
635
|
+
discordClient: Client
|
|
636
|
+
}): void {
|
|
637
|
+
if (
|
|
638
|
+
process.env.KIMAKI_VITEST &&
|
|
639
|
+
process.env.KIMAKI_ENABLE_EXTERNAL_OPENCODE_SYNC !== '1'
|
|
640
|
+
) {
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
if (externalSyncInterval) {
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let polling = false
|
|
648
|
+
const runPoll = async (): Promise<void> => {
|
|
649
|
+
if (polling) {
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
polling = true
|
|
653
|
+
const result = await pollExternalSessions({ discordClient }).catch(
|
|
654
|
+
(e) => new Error('External session poll failed', { cause: e }),
|
|
655
|
+
)
|
|
656
|
+
polling = false
|
|
657
|
+
if (result instanceof Error) {
|
|
658
|
+
logger.warn(`[EXTERNAL_SYNC] ${result.message}`)
|
|
659
|
+
void notifyError(result, 'External session poll top-level failure')
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
void runPoll()
|
|
664
|
+
externalSyncInterval = setInterval(() => {
|
|
665
|
+
void runPoll()
|
|
666
|
+
}, EXTERNAL_SYNC_INTERVAL_MS)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function stopExternalOpencodeSessionSync(): void {
|
|
670
|
+
if (!externalSyncInterval) {
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
clearInterval(externalSyncInterval)
|
|
674
|
+
externalSyncInterval = null
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export const externalOpencodeSyncInternals = {
|
|
678
|
+
getRenderableUserTextParts,
|
|
679
|
+
getSessionThreadName,
|
|
680
|
+
groupTrackedChannelsByDirectory,
|
|
681
|
+
sortSessionsByRecency,
|
|
682
|
+
parseDiscordOriginMetadata,
|
|
683
|
+
getDiscordOriginMetadataFromMessage,
|
|
684
|
+
isLatestUserTurnFromDiscord,
|
|
685
|
+
}
|