@vantran-se/openchamber-web 1.23.1 → 1.24.4
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/cli.test.js +29 -6
- package/bin/lib/cli-http.js +5 -1
- package/bin/lib/commands-status.js +5 -0
- package/dist/assets/{CodeMirrorCommentWidgets-BOpqXpFU.js → CodeMirrorCommentWidgets-B4a0JkUz.js} +1 -1
- package/dist/assets/DiffView-B2CD9wC8.js +4 -0
- package/dist/assets/FilesView-C4vMFclc.js +13 -0
- package/dist/assets/GitView-Byu2DY7s.js +6 -0
- package/dist/assets/GuestAttachDialog-B808E0-d.js +1 -0
- package/dist/assets/{JsonTreeView-nd_E8eVM.js → JsonTreeView-VW_7czo3.js} +1 -1
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/LinearIssuesView-EzGNCGrd.js +1 -0
- package/dist/assets/{MarkdownImageGallery-CVYds3sQ.js → MarkdownImageGallery-B2YUDFaB.js} +1 -1
- package/dist/assets/MarkdownRendererImpl-B4ikIzR3.js +12 -0
- package/dist/assets/{OnboardingScreen-Dd2IbPTn.js → OnboardingScreen-CQHBSTEi.js} +1 -1
- package/dist/assets/{PatchDiff-nUu5i4bG.js → PatchDiff-DCjGhlYV.js} +1 -1
- package/dist/assets/PierreDiffViewer-CEDZNkU2.js +210 -0
- package/dist/assets/PlanView-D3x-pTkZ.js +5 -0
- package/dist/assets/PluginPane-DEcgDQ9h.js +3 -0
- package/dist/assets/SettingsView-CSinuH-1.js +29 -0
- package/dist/assets/{SettingsWindow-BccxY81J.js → SettingsWindow-B5CHoJFZ.js} +1 -1
- package/dist/assets/SubmoduleDiffSummary-oxeACCWs.js +1 -0
- package/dist/assets/ToolOutputDialog-tLNWmfcW.js +15 -0
- package/dist/assets/{ToolPartDiffPreview-BFVgk0UK.js → ToolPartDiffPreview-CflzmK-x.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-z3_H74UR.js → VirtualizedFileDiff-NU0FHFhT.js} +3 -3
- package/dist/assets/{WalkthroughView-BqM9GFbJ.js → WalkthroughView-BZyjhqHv.js} +2 -2
- package/dist/assets/appThemeRegistry-DLicw4dH.js +1 -0
- package/dist/assets/commitAndPush-BDftrjr6.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-Bdazp9Qo.js → computeVirtualFileMetrics-DlWlEM3x.js} +1 -1
- package/dist/assets/de-Bro6_zyl.js +10 -0
- package/dist/assets/es-Bb9XQZvd.js +16 -0
- package/dist/assets/fr-Dgfzn-IL.js +14 -0
- package/dist/assets/{index-eo1-D_7z.js → index-CtQNmqUL.js} +1 -1
- package/dist/assets/index-DqaMWNrC.css +1 -0
- package/dist/assets/index-Dqc6USl3.js +40 -0
- package/dist/assets/{isDiffPlainText-BvKH712-.js → isDiffPlainText-CO4e5J_S.js} +1 -1
- package/dist/assets/{isFilePlainText-BdVVPukf.js → isFilePlainText-ue6kCou7.js} +1 -1
- package/dist/assets/ja-BYsakGFh.js +16 -0
- package/dist/assets/ko-BSaQz9aN.js +16 -0
- package/dist/assets/main-B62_pjL5.js +2 -0
- package/dist/assets/main-BEdcOI2P.js +896 -0
- package/dist/assets/{markdown-shiki.worker-D5fBU-lw.js → markdown-shiki.worker-CahLvHIq.js} +5 -5
- package/dist/assets/markdownCore-CK48LG0z.js +5 -0
- package/dist/assets/miniChat-LT8Rqofr.js +2 -0
- package/dist/assets/mobile-CqarEynh.js +2 -0
- package/dist/assets/{patchFileDiff-B7LjppSS.js → patchFileDiff-D7QVlovD.js} +3 -3
- package/dist/assets/pl-OIviPdoj.js +16 -0
- package/dist/assets/pt-BR-Cltg-E6s.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-COyLTtZx.js +2 -0
- package/dist/assets/renderMobileApp-BC1MHg6g.js +25 -0
- package/dist/assets/runtimeEndpointReset-DdrazOVH.js +25 -0
- package/dist/assets/{scheduledTasksApi-hceovDei.js → scheduledTasksApi-B2SWMGEU.js} +1 -1
- package/dist/assets/shikiHighlight-BPu3JiQs.js +1 -0
- package/dist/assets/textMateThemeFromAppTheme-DWqRPo9A.js +1 -0
- package/dist/assets/tr-CQI-KPre.js +7 -0
- package/dist/assets/uk-CqW7R4DA.js +16 -0
- package/dist/assets/useAppFontEffects-ne84JBVL.js +7333 -0
- package/dist/assets/useInlineCommentController-CgS0fnw_.js +2 -0
- package/dist/assets/useRootScrollLock-Cpf1qoGp.js +2 -0
- package/dist/assets/{vendor-base-ui-BY_5qla_.js → vendor-base-ui-CsXYnR5c.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-DWA1bm1F.js → vendor-codemirror-language-data-Bx2EHc1O.js} +2 -2
- package/dist/assets/vendor-framer-motion-Bq3iTZ6r.js +1 -0
- package/dist/assets/vendor-katex-Cp579UoJ.css +1 -0
- package/dist/assets/{vendor-motion-dom-D_o3BTI3.js → vendor-motion-dom-DcCgeG69.js} +1 -1
- package/dist/assets/{vendor-remixicon-react-Dil0zKKT.js → vendor-remixicon-react-BDmGmFCA.js} +1 -1
- package/dist/assets/vendor-strip-json-comments-5dJlOkP1.js +4 -0
- package/dist/assets/{vendor-tanstack-react-virtual-g05Cx3z9.js → vendor-tanstack-react-virtual-ClYN8CzB.js} +1 -1
- package/dist/assets/{vendor-tanstack-virtual-core-Dubg10g5.js → vendor-tanstack-virtual-core-BewVQryB.js} +1 -1
- package/dist/assets/{vendor-zod-B6551dwG.js → vendor-zod-CgAdXWZO.js} +4 -4
- package/dist/assets/{web-components-B_Z64So5.js → web-components-DxX8bZ1o.js} +2 -2
- package/dist/assets/zh-CN-CVZG_o_J.js +16 -0
- package/dist/assets/zh-TW-D5ZxzCZp.js +16 -0
- package/dist/index.html +6 -4
- package/dist/mini-chat.html +6 -4
- package/dist/mobile.html +6 -4
- package/package.json +12 -9
- package/server/built-in-extensions/registry.json +4 -0
- package/server/index.d.ts +2 -0
- package/server/index.js +44 -10
- package/server/lib/agent-tool/DOCUMENTATION.md +17 -4
- package/server/lib/agent-tool/runtime.js +62 -15
- package/server/lib/agent-tool/runtime.test.js +88 -2
- package/server/lib/changelog/update-notes.js +2 -1
- package/server/lib/changelog/update-notes.test.js +19 -0
- package/server/lib/event-stream/DOCUMENTATION.md +23 -0
- package/server/lib/event-stream/delta-coalescer.js +0 -0
- package/server/lib/event-stream/delta-coalescer.test.js +292 -0
- package/server/lib/event-stream/global-hub.js +53 -23
- package/server/lib/event-stream/global-hub.test.js +160 -0
- package/server/lib/event-stream/global-ws-bridge.js +1 -0
- package/server/lib/event-stream/index.js +4 -0
- package/server/lib/fs/DOCUMENTATION.md +10 -1
- package/server/lib/fs/git-process.test.js +77 -0
- package/server/lib/fs/routes.js +58 -5
- package/server/lib/fs/routes.test.js +116 -0
- package/server/lib/fs/search.js +2 -1
- package/server/lib/git/DOCUMENTATION.md +20 -5
- package/server/lib/git/routes.js +16 -3
- package/server/lib/git/serial-refresh.js +93 -0
- package/server/lib/git/serial-refresh.test.js +112 -0
- package/server/lib/git/service.js +429 -70
- package/server/lib/git/service.test.js +418 -0
- package/server/lib/github/pr-status.js +5 -5
- package/server/lib/github/pr-status.test.js +1 -1
- package/server/lib/github/routes.js +4 -4
- package/server/lib/guests/DOCUMENTATION.md +68 -0
- package/server/lib/guests/auth-store.js +184 -0
- package/server/lib/guests/auth-store.test.js +112 -0
- package/server/lib/guests/background.test.js +83 -0
- package/server/lib/guests/builtins.js +34 -0
- package/server/lib/guests/builtins.test.js +174 -0
- package/server/lib/guests/catalog.js +477 -0
- package/server/lib/guests/catalog.test.js +390 -0
- package/server/lib/guests/clone.js +297 -0
- package/server/lib/guests/extract-zip.js +136 -0
- package/server/lib/guests/extract-zip.test.js +109 -0
- package/server/lib/guests/files.js +308 -0
- package/server/lib/guests/files.test.js +262 -0
- package/server/lib/guests/grant-scope.js +81 -0
- package/server/lib/guests/grant-scope.test.js +75 -0
- package/server/lib/guests/host-session.js +73 -0
- package/server/lib/guests/host-session.test.js +30 -0
- package/server/lib/guests/html-styles.js +6 -0
- package/server/lib/guests/html-styles.test.js +45 -0
- package/server/lib/guests/html-tokens.js +73 -0
- package/server/lib/guests/html-tokens.test.js +53 -0
- package/server/lib/guests/install.js +406 -0
- package/server/lib/guests/install.test.js +338 -0
- package/server/lib/guests/oauth.js +501 -0
- package/server/lib/guests/oauth.test.js +481 -0
- package/server/lib/guests/persist.js +336 -0
- package/server/lib/guests/persist.test.js +288 -0
- package/server/lib/guests/request.js +112 -0
- package/server/lib/guests/request.test.js +206 -0
- package/server/lib/guests/routes.js +838 -0
- package/server/lib/guests/service.js +538 -0
- package/server/lib/guests/service.test.js +277 -0
- package/server/lib/guests/sockets.js +129 -0
- package/server/lib/guests/sockets.test.js +110 -0
- package/server/lib/guests/ssh-install.test.js +188 -0
- package/server/lib/guests/storage.js +68 -0
- package/server/lib/guests/storage.test.js +59 -0
- package/server/lib/guests/updates.js +294 -0
- package/server/lib/guests/updates.test.js +334 -0
- package/server/lib/guests/upload.js +50 -0
- package/server/lib/guests/upload.test.js +131 -0
- package/server/lib/linear/DOCUMENTATION.md +1 -1
- package/server/lib/message-queue/DOCUMENTATION.md +10 -1
- package/server/lib/message-queue/runtime.js +21 -1
- package/server/lib/message-queue/runtime.test.js +52 -5
- package/server/lib/notifications/routes.js +5 -0
- package/server/lib/openchamber-control/DOCUMENTATION.md +6 -0
- package/server/lib/openchamber-sessions/routes.js +62 -29
- package/server/lib/openchamber-sessions/routes.test.js +96 -0
- package/server/lib/opencode/DOCUMENTATION.md +55 -1
- package/server/lib/opencode/bootstrap-runtime.js +1 -0
- package/server/lib/opencode/core-routes.js +39 -1
- package/server/lib/opencode/feature-routes-runtime.js +15 -0
- package/server/lib/opencode/lifecycle-process.test.js +84 -0
- package/server/lib/opencode/lifecycle.js +54 -67
- package/server/lib/opencode/openchamber-routes.js +72 -14
- package/server/lib/opencode/openchamber-routes.test.js +71 -3
- package/server/lib/opencode/session-runtime.js +68 -0
- package/server/lib/opencode/session-runtime.test.js +43 -0
- package/server/lib/opencode/settings-helpers.js +8 -0
- package/server/lib/opencode/settings-helpers.test.js +13 -1
- package/server/lib/opencode/settings-normalization-runtime.js +2 -0
- package/server/lib/opencode/settings-normalization-runtime.test.js +13 -0
- package/server/lib/opencode/settings-registry.json +15 -0
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/shutdown-runtime.test.js +24 -0
- package/server/lib/opencode/theme-archive.js +53 -0
- package/server/lib/opencode/theme-catalog.js +171 -0
- package/server/lib/opencode/theme-catalog.test.js +125 -0
- package/server/lib/opencode/theme-import.test.js +144 -0
- package/server/lib/opencode/theme-runtime.js +100 -28
- package/server/lib/opencode/theme-runtime.test.js +15 -0
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +8 -0
- package/server/lib/permission-auto-accept/runtime.js +13 -0
- package/server/lib/permission-auto-accept/runtime.test.js +37 -1
- package/server/lib/projects/project-config.js +2 -2
- package/server/lib/quota/DOCUMENTATION.md +0 -1
- package/server/lib/quota/providers/index.js +0 -7
- package/server/lib/realtime-proxy-terminal.test.js +150 -0
- package/server/lib/realtime-proxy.js +2 -0
- package/server/lib/routing/DOCUMENTATION.md +83 -0
- package/server/lib/routing/defaults.js +98 -0
- package/server/lib/routing/feature-flag.js +11 -0
- package/server/lib/routing/history.js +34 -0
- package/server/lib/routing/jev.js +98 -0
- package/server/lib/routing/routes.http.test.js +100 -0
- package/server/lib/routing/routes.js +81 -0
- package/server/lib/routing/runtime.js +217 -0
- package/server/lib/routing/runtime.test.js +188 -0
- package/server/lib/routing/store.js +249 -0
- package/server/lib/routing/store.test.js +96 -0
- package/server/lib/scheduled-tasks/runtime.js +2 -2
- package/server/lib/scheduled-tasks/runtime.test.js +14 -0
- package/server/lib/security/request-security.js +9 -5
- package/server/lib/security/request-security.test.js +37 -0
- package/server/lib/small-model/DOCUMENTATION.md +14 -2
- package/server/lib/small-model/call.js +37 -6
- package/server/lib/small-model/call.test.js +182 -0
- package/server/lib/terminal/DOCUMENTATION.md +23 -2
- package/server/lib/terminal/runtime.js +27 -5
- package/server/lib/terminal/runtime.test.js +67 -0
- package/server/lib/terminal/shutdown.js +106 -0
- package/server/lib/terminal/shutdown.test.js +84 -0
- package/server/lib/tts/service.test.js +78 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +8 -0
- package/server/lib/ui-auth/session-cookie.js +51 -0
- package/server/lib/ui-auth/session-cookie.test.js +46 -0
- package/server/lib/ui-auth/ui-auth.js +56 -17
- package/server/lib/ui-auth/ui-auth.test.js +96 -0
- package/dist/assets/DiffView-CyLmb-wF.js +0 -4
- package/dist/assets/DirtyBranchSwitchDialog-pu5kcoir.js +0 -1
- package/dist/assets/FilesView-WtJB0y_l.js +0 -13
- package/dist/assets/GitView-DkNrV0BK.js +0 -6
- package/dist/assets/LinearIssuesView-B0H0Otii.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DSqVN2d8.js +0 -12
- package/dist/assets/PierreDiffViewer-Bsq9z-sw.js +0 -161
- package/dist/assets/PlanView-CAFziKH5.js +0 -5
- package/dist/assets/SettingsView-BRittavZ.js +0 -28
- package/dist/assets/ToolOutputDialog-B0uYaJio.js +0 -15
- package/dist/assets/appThemeRegistry-BK0dHKa6.js +0 -1
- package/dist/assets/de-c7CYLCAW.js +0 -10
- package/dist/assets/es-BKAVPQmQ.js +0 -16
- package/dist/assets/fr-Bj1glqfV.js +0 -14
- package/dist/assets/index-BYFyOu_D.css +0 -1
- package/dist/assets/index-BfwRClDe.js +0 -40
- package/dist/assets/ja-5jwSIbK0.js +0 -16
- package/dist/assets/ko-BaDbg-Eb.js +0 -16
- package/dist/assets/main-CGG2_uHq.js +0 -2
- package/dist/assets/main-CcrSaL4J.js +0 -930
- package/dist/assets/markdownCore-DsmQr3bt.js +0 -5
- package/dist/assets/miniChat-DEaAhP4b.js +0 -2
- package/dist/assets/mobile-BC3rsvv0.js +0 -2
- package/dist/assets/pl-BFKVBDFu.js +0 -16
- package/dist/assets/pt-BR-qKfHvkDL.js +0 -16
- package/dist/assets/registerCustomTheme-BBXUCRnG.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-CR9d1Mr0.js +0 -2
- package/dist/assets/renderMobileApp-Da5dwobZ.js +0 -25
- package/dist/assets/runtimeEndpointReset-CLYVRw2H.js +0 -41
- package/dist/assets/shikiHighlight-D1Z9G58W.js +0 -1
- package/dist/assets/tr-DYXPoEDG.js +0 -7
- package/dist/assets/uk-CwAoloR8.js +0 -16
- package/dist/assets/useAppFontEffects-DeStGcUT.js +0 -7282
- package/dist/assets/useGitComparison-CpxioduU.js +0 -1
- package/dist/assets/useInlineCommentController-gmUnJtrb.js +0 -2
- package/dist/assets/useRootScrollLock--a3cPz8s.js +0 -1
- package/dist/assets/vendor-framer-motion-livSKyyV.js +0 -1
- package/dist/assets/zh-CN-D9MG0lqF.js +0 -16
- package/dist/assets/zh-TW-1HNK7shh.js +0 -16
- package/server/lib/quota/providers/crof.js +0 -96
- package/server/lib/quota/providers/crof.test.js +0 -98
- /package/dist/assets/{vendor-katex-k3Ppq1Ha.js → vendor-katex-CHD9BdDO.js} +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns Jev routing at runtime: whether Auto is ready, rewriting a prompt body
|
|
3
|
+
* that names the `openchamber/auto` model, and the safety net consulted before
|
|
4
|
+
* a permission is auto-accepted. Every failure path keeps the user's own
|
|
5
|
+
* behaviour: a prompt goes to the fallback model, a permission is accepted as
|
|
6
|
+
* auto-accept would have, and the UI is told why.
|
|
7
|
+
*/
|
|
8
|
+
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { isRoutingFeatureAvailable } from './feature-flag.js';
|
|
11
|
+
import { BUILTIN_CATEGORIES, isAutoModel } from './defaults.js';
|
|
12
|
+
import { createRoutingStore, parseEffectiveConfig } from './store.js';
|
|
13
|
+
import { buildPermissionRequest, buildRoutingRequest, createJevClient, decidePermission, decideRouting } from './jev.js';
|
|
14
|
+
import { loadRoutingHistory } from './history.js';
|
|
15
|
+
|
|
16
|
+
const HISTORY_TIMEOUT_MS = 2500;
|
|
17
|
+
/** A held permission is remembered so reconnect reconciliation does not re-ask Jev. */
|
|
18
|
+
const PERMISSION_DECISION_TTL_MS = 15 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
|
|
21
|
+
|
|
22
|
+
const textPartSchema = z.object({ type: z.literal('text'), text: z.string(), synthetic: z.boolean().optional() });
|
|
23
|
+
const commandBodySchema = z.object({ command: z.string(), arguments: z.string().optional() });
|
|
24
|
+
const promptBodySchema = z.object({ parts: z.array(z.unknown()).optional() });
|
|
25
|
+
|
|
26
|
+
/** The user's words for this send: text parts the composer authored, or the slash command. */
|
|
27
|
+
export const requestTextOf = (body) => {
|
|
28
|
+
const command = commandBodySchema.safeParse(body);
|
|
29
|
+
if (command.success) {
|
|
30
|
+
const args = command.data.arguments?.trim();
|
|
31
|
+
return `/${command.data.command}${args ? ` ${args}` : ''}`;
|
|
32
|
+
}
|
|
33
|
+
const prompt = promptBodySchema.safeParse(body);
|
|
34
|
+
const parts = prompt.success ? prompt.data.parts ?? [] : [];
|
|
35
|
+
return parts
|
|
36
|
+
.map((part) => textPartSchema.safeParse(part))
|
|
37
|
+
.filter((part) => part.success && !part.data.synthetic)
|
|
38
|
+
.map((part) => part.data.text)
|
|
39
|
+
.join('\n\n')
|
|
40
|
+
.trim();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function createRoutingRuntime({
|
|
44
|
+
dataDir,
|
|
45
|
+
buildOpenCodeUrl,
|
|
46
|
+
getOpenCodeAuthHeaders,
|
|
47
|
+
broadcastGlobalUiEvent,
|
|
48
|
+
fetchImpl = fetch,
|
|
49
|
+
store = createRoutingStore({ dataDir }),
|
|
50
|
+
jev = createJevClient({ fetchImpl }),
|
|
51
|
+
now = Date.now,
|
|
52
|
+
}) {
|
|
53
|
+
const permissionDecisions = new Map();
|
|
54
|
+
|
|
55
|
+
const broadcast = (type, properties) => {
|
|
56
|
+
try {
|
|
57
|
+
broadcastGlobalUiEvent?.({ type, properties });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn(`[routing] failed to broadcast ${type}:`, errorMessage(error));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const enabledCategories = (config) => config.categories.filter((category) => category.enabled);
|
|
64
|
+
|
|
65
|
+
/** What the client needs to decide whether to offer Auto and what the settings page shows. */
|
|
66
|
+
const describe = async () => {
|
|
67
|
+
const available = isRoutingFeatureAvailable();
|
|
68
|
+
if (!available) return { available: false, autoReady: false, tokenPresent: false, config: null, builtins: [] };
|
|
69
|
+
const [config, token] = await Promise.all([store.readConfig(), store.readToken()]);
|
|
70
|
+
const tokenPresent = Boolean(token);
|
|
71
|
+
const autoReady = config.enabled && tokenPresent && Boolean(config.fallback) && enabledCategories(config).length >= 2;
|
|
72
|
+
// Built-in text travels with the config so "Reset" in Settings restores the shipped wording.
|
|
73
|
+
return { available, autoReady, tokenPresent, config, builtins: BUILTIN_CATEGORIES };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const publishUpdated = async () => {
|
|
77
|
+
const state = await describe();
|
|
78
|
+
broadcast('openchamber:routing.updated', { available: state.available, autoReady: state.autoReady, tokenPresent: state.tokenPresent });
|
|
79
|
+
return state;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const readHistory = async ({ sessionId, directory }) => {
|
|
83
|
+
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
|
84
|
+
const client = createOpencodeClient({ baseUrl, headers: getOpenCodeAuthHeaders(), throwOnError: true });
|
|
85
|
+
const signal = AbortSignal.timeout(HISTORY_TIMEOUT_MS);
|
|
86
|
+
return loadRoutingHistory({
|
|
87
|
+
signal,
|
|
88
|
+
readPage: (page) => client.session.messages({ sessionID: sessionId, directory, ...page }, { signal }),
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// A category without a model of its own means "the fallback pair"; a variant
|
|
93
|
+
// only travels with the model it was chosen for.
|
|
94
|
+
const applyChoice = (body, config, choice) => {
|
|
95
|
+
const own = Boolean(choice?.model);
|
|
96
|
+
const model = own ? choice.model : config.fallback.model;
|
|
97
|
+
const variant = own ? choice.variant : config.fallback.variant;
|
|
98
|
+
// Keep the wire shape the route uses: a string on /command, an object on the prompt routes.
|
|
99
|
+
body.model = z.string().safeParse(body.model).success
|
|
100
|
+
? `${model.providerID}/${model.modelID}`
|
|
101
|
+
: { providerID: model.providerID, modelID: model.modelID };
|
|
102
|
+
if (variant) body.variant = variant;
|
|
103
|
+
else delete body.variant;
|
|
104
|
+
if (choice?.agent) body.agent = choice.agent;
|
|
105
|
+
return { providerID: model.providerID, modelID: model.modelID, variant: variant ?? null, agent: choice?.agent ?? null };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rewrites `body.model` in place when it is the Auto sentinel. Returns the
|
|
110
|
+
* decision that was applied, or null when the body named a real model.
|
|
111
|
+
* Throws only when Auto cannot be honoured at all (no fallback configured):
|
|
112
|
+
* the sentinel must never reach OpenCode.
|
|
113
|
+
*/
|
|
114
|
+
const resolvePromptBody = async (body, { sessionId, directory }) => {
|
|
115
|
+
if (!isAutoModel(body?.model)) return null;
|
|
116
|
+
const state = await describe();
|
|
117
|
+
const config = state.config;
|
|
118
|
+
if (!config?.fallback) {
|
|
119
|
+
throw Object.assign(new Error('Auto routing is selected but no fallback model is configured'), { status: 400 });
|
|
120
|
+
}
|
|
121
|
+
const decision = { sessionId, at: now(), category: null, confidence: 0, reason: 'not-ready', ms: 0 };
|
|
122
|
+
if (state.autoReady) {
|
|
123
|
+
const request = requestTextOf(body);
|
|
124
|
+
let history = [];
|
|
125
|
+
try {
|
|
126
|
+
history = await readHistory({ sessionId, directory });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.warn('[routing] history unavailable, routing on the request alone:', errorMessage(error));
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const token = await store.readToken();
|
|
132
|
+
const { answers, ms } = await jev.ask(buildRoutingRequest({ categories: enabledCategories(config), history, request }), token);
|
|
133
|
+
const result = decideRouting(answers.category, { categories: enabledCategories(config), minConfidence: config.minConfidence });
|
|
134
|
+
decision.category = result.category?.id ?? null;
|
|
135
|
+
decision.confidence = result.confidence;
|
|
136
|
+
decision.reason = result.reason;
|
|
137
|
+
decision.ms = ms;
|
|
138
|
+
Object.assign(decision, applyChoice(body, config, result.category));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
decision.reason = 'error';
|
|
141
|
+
decision.error = errorMessage(error);
|
|
142
|
+
Object.assign(decision, applyChoice(body, config, null));
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
Object.assign(decision, applyChoice(body, config, null));
|
|
146
|
+
}
|
|
147
|
+
broadcast('openchamber:routing.decision', decision);
|
|
148
|
+
return decision;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Consulted by permission auto-accept before it replies. `accept` keeps the
|
|
153
|
+
* reply; `hold` leaves the request for the user; `skipped` is `accept` with
|
|
154
|
+
* a reason the UI surfaces (Jev unreachable, bad key).
|
|
155
|
+
*/
|
|
156
|
+
const evaluatePermission = async (permission, directory) => {
|
|
157
|
+
if (!permission?.id) return { action: 'accept' };
|
|
158
|
+
const cached = permissionDecisions.get(permission.id);
|
|
159
|
+
if (cached && now() - cached.at < PERMISSION_DECISION_TTL_MS) return cached.result;
|
|
160
|
+
const state = await describe();
|
|
161
|
+
if (!state.available || !state.config?.enabled || !state.config.safetyNet.enabled || !state.tokenPresent) return { action: 'accept' };
|
|
162
|
+
let result;
|
|
163
|
+
try {
|
|
164
|
+
const token = await store.readToken();
|
|
165
|
+
const { answers } = await jev.ask(buildPermissionRequest(permission), token);
|
|
166
|
+
const verdict = decidePermission(answers, { threshold: state.config.safetyNet.threshold });
|
|
167
|
+
result = verdict.hold
|
|
168
|
+
? { action: 'hold', score: verdict.score, kind: verdict.kind }
|
|
169
|
+
: { action: 'accept', score: verdict.score, kind: verdict.kind };
|
|
170
|
+
if (verdict.hold) {
|
|
171
|
+
broadcast('openchamber:routing.permission-held', {
|
|
172
|
+
permissionId: permission.id, sessionId: permission.sessionID, directory: directory ?? null, score: verdict.score, kind: verdict.kind,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
result = { action: 'accept', skipped: errorMessage(error) };
|
|
177
|
+
broadcast('openchamber:routing.safety-skipped', {
|
|
178
|
+
permissionId: permission.id, sessionId: permission.sessionID, directory: directory ?? null, error: result.skipped,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
permissionDecisions.set(permission.id, { at: now(), result });
|
|
182
|
+
return result;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const forgetPermission = (permissionId) => {
|
|
186
|
+
permissionDecisions.delete(permissionId);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const updateConfig = async (input) => {
|
|
190
|
+
const config = parseEffectiveConfig(input);
|
|
191
|
+
await store.writeConfig(config);
|
|
192
|
+
return publishUpdated();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const setToken = async (token) => {
|
|
196
|
+
const parsed = z.string().trim().min(1).max(4000).safeParse(token);
|
|
197
|
+
if (!parsed.success) throw Object.assign(new Error('A Jev API key is required'), { status: 400 });
|
|
198
|
+
await store.writeToken(parsed.data);
|
|
199
|
+
return publishUpdated();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const clearToken = async () => {
|
|
203
|
+
await store.clearToken();
|
|
204
|
+
return publishUpdated();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Held permissions the UI can read back after a reload. */
|
|
208
|
+
const heldPermissions = () => {
|
|
209
|
+
const held = [];
|
|
210
|
+
for (const [permissionId, entry] of permissionDecisions) {
|
|
211
|
+
if (entry.result.action === 'hold') held.push({ permissionId, score: entry.result.score, kind: entry.result.kind });
|
|
212
|
+
}
|
|
213
|
+
return held;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return { describe, resolvePromptBody, evaluatePermission, forgetPermission, heldPermissions, updateConfig, setToken, clearToken };
|
|
217
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createRoutingRuntime, requestTextOf } from './runtime.js';
|
|
3
|
+
import { resolveEffectiveConfig } from './store.js';
|
|
4
|
+
import { excerptHead, excerptHeadTail, turnsToHistory } from './history.js';
|
|
5
|
+
import { decidePermission, decideRouting } from './jev.js';
|
|
6
|
+
|
|
7
|
+
const AUTO = { providerID: 'openchamber', modelID: 'auto' };
|
|
8
|
+
const FALLBACK = { model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' }, variant: 'medium' };
|
|
9
|
+
|
|
10
|
+
const readyConfig = () => {
|
|
11
|
+
const config = resolveEffectiveConfig(null);
|
|
12
|
+
config.enabled = true;
|
|
13
|
+
config.fallback = FALLBACK;
|
|
14
|
+
config.safetyNet = { enabled: true, threshold: 0.6 };
|
|
15
|
+
config.categories = config.categories.map((c) => (c.id === 'hard'
|
|
16
|
+
? { ...c, model: { providerID: 'openai', modelID: 'gpt-6-astra' }, variant: 'high', agent: 'plan' }
|
|
17
|
+
: c));
|
|
18
|
+
return config;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const makeRuntime = ({ config = readyConfig(), token = 'key', answers, askError, flag = '1' } = {}) => {
|
|
22
|
+
process.env.OPENCHAMBER_ROUTING_ENABLE = flag;
|
|
23
|
+
const events = [];
|
|
24
|
+
const store = {
|
|
25
|
+
readConfig: vi.fn(async () => config),
|
|
26
|
+
writeConfig: vi.fn(async (next) => next),
|
|
27
|
+
readToken: vi.fn(async () => token),
|
|
28
|
+
writeToken: vi.fn(async () => undefined),
|
|
29
|
+
clearToken: vi.fn(async () => undefined),
|
|
30
|
+
};
|
|
31
|
+
const jev = { ask: vi.fn(async () => { if (askError) throw askError; return { answers, ms: 12 }; }) };
|
|
32
|
+
const runtime = createRoutingRuntime({
|
|
33
|
+
dataDir: '/unused',
|
|
34
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:1/',
|
|
35
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
36
|
+
broadcastGlobalUiEvent: (event) => events.push(event),
|
|
37
|
+
store,
|
|
38
|
+
jev,
|
|
39
|
+
});
|
|
40
|
+
return { runtime, store, jev, events };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('requestTextOf', () => {
|
|
44
|
+
it('joins authored text parts and ignores synthetic ones and files', () => {
|
|
45
|
+
expect(requestTextOf({ parts: [
|
|
46
|
+
{ type: 'text', text: 'fix the typo' },
|
|
47
|
+
{ type: 'text', text: 'injected', synthetic: true },
|
|
48
|
+
{ type: 'file', url: 'data:...' },
|
|
49
|
+
{ type: 'text', text: 'in README' },
|
|
50
|
+
] })).toBe('fix the typo\n\nin README');
|
|
51
|
+
});
|
|
52
|
+
it('renders a slash command with its arguments', () => {
|
|
53
|
+
expect(requestTextOf({ command: 'review', arguments: ' 3650 ' })).toBe('/review 3650');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('history excerpts', () => {
|
|
58
|
+
it('keeps the head of a user message and head plus tail of an answer', () => {
|
|
59
|
+
const long = 'a'.repeat(1000);
|
|
60
|
+
expect(excerptHead(long, 600)).toBe(`${'a'.repeat(600)} […]`);
|
|
61
|
+
expect(excerptHeadTail(`${'h'.repeat(400)}${'m'.repeat(400)}${'t'.repeat(400)}`, 300, 300)).toBe(`${'h'.repeat(300)} […] ${'t'.repeat(300)}`);
|
|
62
|
+
expect(excerptHead('short', 600)).toBe('short');
|
|
63
|
+
});
|
|
64
|
+
it('flattens the last three turns oldest first', () => {
|
|
65
|
+
const turns = [1, 2, 3, 4].map((n) => ({ user: { text: `u${n}` }, assistant: { text: `a${n}` } }));
|
|
66
|
+
expect(turnsToHistory(turns)).toEqual([
|
|
67
|
+
{ role: 'user', text: 'u2' }, { role: 'assistant', text: 'a2' },
|
|
68
|
+
{ role: 'user', text: 'u3' }, { role: 'assistant', text: 'a3' },
|
|
69
|
+
{ role: 'user', text: 'u4' }, { role: 'assistant', text: 'a4' },
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('decisions', () => {
|
|
75
|
+
const categories = readyConfig().categories;
|
|
76
|
+
it('routes a confident known category and falls back otherwise', () => {
|
|
77
|
+
expect(decideRouting({ choice: 'hard', confidence: 0.9 }, { categories, minConfidence: 0.6 }).reason).toBe('routed');
|
|
78
|
+
expect(decideRouting({ choice: 'hard', confidence: 0.4 }, { categories, minConfidence: 0.6 })).toMatchObject({ category: null, reason: 'low-confidence' });
|
|
79
|
+
expect(decideRouting({ choice: 'nope', confidence: 0.99 }, { categories, minConfidence: 0.6 })).toMatchObject({ category: null, reason: 'unknown-category' });
|
|
80
|
+
});
|
|
81
|
+
it('holds a permission at or above the threshold', () => {
|
|
82
|
+
expect(decidePermission({ ask: { noul: 0.61 }, kind: { choice: 'git_history' } }, { threshold: 0.6 })).toEqual({ hold: true, score: 0.61, kind: 'git_history' });
|
|
83
|
+
expect(decidePermission({ ask: { noul: 0.2 }, kind: { choice: 'read_only' } }, { threshold: 0.6 }).hold).toBe(false);
|
|
84
|
+
expect(() => decidePermission({}, { threshold: 0.6 })).toThrow(/ask score/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('resolvePromptBody', () => {
|
|
89
|
+
it('leaves a real model untouched and does not consult Jev', async () => {
|
|
90
|
+
const { runtime, jev } = makeRuntime({ answers: {} });
|
|
91
|
+
const body = { model: { providerID: 'anthropic', modelID: 'claude-opus-5' }, parts: [] };
|
|
92
|
+
expect(await runtime.resolvePromptBody(body, { sessionId: 's1' })).toBeNull();
|
|
93
|
+
expect(body.model.modelID).toBe('claude-opus-5');
|
|
94
|
+
expect(jev.ask).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('rewrites the sentinel with the routed category model, variant and agent', async () => {
|
|
98
|
+
const { runtime, events } = makeRuntime({ answers: { category: { choice: 'hard', confidence: 0.97 } } });
|
|
99
|
+
const body = { model: AUTO, variant: 'low', agent: 'build', parts: [{ type: 'text', text: 'find the root cause' }] };
|
|
100
|
+
const decision = await runtime.resolvePromptBody(body, { sessionId: 's1' });
|
|
101
|
+
expect(body).toMatchObject({ model: { providerID: 'openai', modelID: 'gpt-6-astra' }, variant: 'high', agent: 'plan' });
|
|
102
|
+
expect(decision).toMatchObject({ category: 'hard', reason: 'routed', confidence: 0.97 });
|
|
103
|
+
expect(events.at(-1)).toMatchObject({ type: 'openchamber:routing.decision', properties: { sessionId: 's1', category: 'hard' } });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('rewrites the string sentinel the command route sends, keeping the string shape', async () => {
|
|
107
|
+
const { runtime } = makeRuntime({ answers: { category: { choice: 'hard', confidence: 0.97 } } });
|
|
108
|
+
const body = { model: 'openchamber/auto', command: 'review', arguments: '3650' };
|
|
109
|
+
await runtime.resolvePromptBody(body, { sessionId: 's1' });
|
|
110
|
+
expect(body.model).toBe('openai/gpt-6-astra');
|
|
111
|
+
expect(body.agent).toBe('plan');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('uses the fallback and keeps the composer agent when the category has no model', async () => {
|
|
115
|
+
const { runtime } = makeRuntime({ answers: { category: { choice: 'trivial', confidence: 0.99 } } });
|
|
116
|
+
const body = { model: AUTO, variant: 'high', agent: 'build', parts: [{ type: 'text', text: 'fix typo' }] };
|
|
117
|
+
await runtime.resolvePromptBody(body, { sessionId: 's1' });
|
|
118
|
+
expect(body).toMatchObject({ model: FALLBACK.model, variant: 'medium', agent: 'build' });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('falls back on low confidence and on a Jev failure, and records why', async () => {
|
|
122
|
+
const low = makeRuntime({ answers: { category: { choice: 'hard', confidence: 0.3 } } });
|
|
123
|
+
const body = { model: AUTO, parts: [] };
|
|
124
|
+
expect((await low.runtime.resolvePromptBody(body, { sessionId: 's1' })).reason).toBe('low-confidence');
|
|
125
|
+
expect(body.model).toEqual(FALLBACK.model);
|
|
126
|
+
|
|
127
|
+
const failing = makeRuntime({ askError: Object.assign(new Error('Jev responded 401'), { status: 401 }) });
|
|
128
|
+
const body2 = { model: AUTO, parts: [] };
|
|
129
|
+
const decision = await failing.runtime.resolvePromptBody(body2, { sessionId: 's1' });
|
|
130
|
+
expect(decision).toMatchObject({ reason: 'error', error: 'Jev responded 401' });
|
|
131
|
+
expect(body2.model).toEqual(FALLBACK.model);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('falls back without asking Jev while Auto is not ready, and refuses without a fallback', async () => {
|
|
135
|
+
const config = readyConfig();
|
|
136
|
+
config.enabled = false;
|
|
137
|
+
const notReady = makeRuntime({ config, answers: {} });
|
|
138
|
+
const body = { model: AUTO, parts: [] };
|
|
139
|
+
expect((await notReady.runtime.resolvePromptBody(body, { sessionId: 's1' })).reason).toBe('not-ready');
|
|
140
|
+
expect(body.model).toEqual(FALLBACK.model);
|
|
141
|
+
expect(notReady.jev.ask).not.toHaveBeenCalled();
|
|
142
|
+
|
|
143
|
+
const noFallback = makeRuntime({ config: { ...readyConfig(), fallback: null }, answers: {} });
|
|
144
|
+
await expect(noFallback.runtime.resolvePromptBody({ model: AUTO, parts: [] }, { sessionId: 's1' })).rejects.toMatchObject({ status: 400 });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('evaluatePermission', () => {
|
|
149
|
+
const permission = { id: 'p1', sessionID: 's1', permission: 'bash', patterns: ['git push --force'], metadata: { command: 'git push --force origin main' } };
|
|
150
|
+
|
|
151
|
+
it('holds a risky permission and remembers the decision', async () => {
|
|
152
|
+
const { runtime, jev, events } = makeRuntime({ answers: { ask: { noul: 0.9 }, kind: { choice: 'git_history' } } });
|
|
153
|
+
expect(await runtime.evaluatePermission(permission, '/repo')).toEqual({ action: 'hold', score: 0.9, kind: 'git_history' });
|
|
154
|
+
expect(await runtime.evaluatePermission(permission, '/repo')).toEqual({ action: 'hold', score: 0.9, kind: 'git_history' });
|
|
155
|
+
expect(jev.ask).toHaveBeenCalledTimes(1);
|
|
156
|
+
expect(events.filter((e) => e.type === 'openchamber:routing.permission-held')).toHaveLength(1);
|
|
157
|
+
expect(runtime.heldPermissions()).toEqual([{ permissionId: 'p1', score: 0.9, kind: 'git_history' }]);
|
|
158
|
+
runtime.forgetPermission('p1');
|
|
159
|
+
expect(runtime.heldPermissions()).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('accepts when Jev is unreachable and tells the UI it skipped', async () => {
|
|
163
|
+
const { runtime, events } = makeRuntime({ askError: Object.assign(new Error('Jev timed out after 4000ms'), { code: 'timeout' }) });
|
|
164
|
+
expect(await runtime.evaluatePermission(permission, '/repo')).toEqual({ action: 'accept', skipped: 'Jev timed out after 4000ms' });
|
|
165
|
+
expect(events.at(-1)).toMatchObject({ type: 'openchamber:routing.safety-skipped', properties: { permissionId: 'p1', error: 'Jev timed out after 4000ms' } });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('accepts without asking when the safety net is off, the key is missing, or the flag is unset', async () => {
|
|
169
|
+
const off = readyConfig();
|
|
170
|
+
off.safetyNet.enabled = false;
|
|
171
|
+
for (const setup of [{ config: off }, { token: null }, { flag: '' }]) {
|
|
172
|
+
const { runtime, jev } = makeRuntime({ ...setup, answers: {} });
|
|
173
|
+
expect(await runtime.evaluatePermission(permission, '/repo')).toEqual({ action: 'accept' });
|
|
174
|
+
expect(jev.ask).not.toHaveBeenCalled();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('describe', () => {
|
|
180
|
+
it('reports Auto ready only with the flag, enabled config, key, fallback and two categories', async () => {
|
|
181
|
+
expect((await makeRuntime({ answers: {} }).runtime.describe()).autoReady).toBe(true);
|
|
182
|
+
expect((await makeRuntime({ token: null, answers: {} }).runtime.describe())).toMatchObject({ autoReady: false, tokenPresent: false });
|
|
183
|
+
const one = readyConfig();
|
|
184
|
+
one.categories = one.categories.map((c, i) => ({ ...c, enabled: i === 0 }));
|
|
185
|
+
expect((await makeRuntime({ config: one, answers: {} }).runtime.describe()).autoReady).toBe(false);
|
|
186
|
+
expect(await makeRuntime({ flag: '', answers: {} }).runtime.describe()).toEqual({ available: false, autoReady: false, tokenPresent: false, config: null, builtins: [] });
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing configuration on disk.
|
|
3
|
+
*
|
|
4
|
+
* `routing.json` holds only deviations from the built-in defaults: a patched or
|
|
5
|
+
* disabled built-in category, a user category, the fallback model, thresholds.
|
|
6
|
+
* `routing-auth.json` holds the Jev API key alone, mode 0600, so the config
|
|
7
|
+
* file can be read, shown and exported without carrying a secret.
|
|
8
|
+
*
|
|
9
|
+
* Reads never throw on a missing file (a fresh install is the defaults); a
|
|
10
|
+
* malformed file is an error, not an empty config, so a bad write cannot
|
|
11
|
+
* silently reset the user's categories.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import {
|
|
17
|
+
BUILTIN_CATEGORIES,
|
|
18
|
+
DEFAULT_MIN_CONFIDENCE,
|
|
19
|
+
DEFAULT_SAFETY_THRESHOLD,
|
|
20
|
+
THINKING_LEVELS,
|
|
21
|
+
isAutoModel,
|
|
22
|
+
} from './defaults.js';
|
|
23
|
+
|
|
24
|
+
const FILE_VERSION = 1;
|
|
25
|
+
const CATEGORY_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
26
|
+
|
|
27
|
+
// The Auto sentinel is what routing resolves *from*; as a fallback or category
|
|
28
|
+
// model it would send itself to OpenCode.
|
|
29
|
+
const modelSchema = z.object({
|
|
30
|
+
providerID: z.string().trim().min(1).max(200),
|
|
31
|
+
modelID: z.string().trim().min(1).max(200),
|
|
32
|
+
}).strict().refine((model) => !isAutoModel(model), { message: 'The Auto model cannot be a routing target' });
|
|
33
|
+
|
|
34
|
+
const variantSchema = z.string().trim().min(1).max(80).nullable();
|
|
35
|
+
const agentSchema = z.string().trim().min(1).max(80).nullable();
|
|
36
|
+
|
|
37
|
+
/** A built-in category on disk carries only what the user changed. */
|
|
38
|
+
const builtinOverrideSchema = z.object({
|
|
39
|
+
builtin: z.literal(true),
|
|
40
|
+
disabled: z.boolean().optional(),
|
|
41
|
+
deleted: z.boolean().optional(),
|
|
42
|
+
name: z.string().trim().min(1).max(60).optional(),
|
|
43
|
+
description: z.string().trim().min(1).max(2000).optional(),
|
|
44
|
+
model: modelSchema.nullable().optional(),
|
|
45
|
+
variant: variantSchema.optional(),
|
|
46
|
+
agent: agentSchema.optional(),
|
|
47
|
+
}).strict();
|
|
48
|
+
|
|
49
|
+
const userCategorySchema = z.object({
|
|
50
|
+
builtin: z.literal(false),
|
|
51
|
+
disabled: z.boolean().optional(),
|
|
52
|
+
name: z.string().trim().min(1).max(60),
|
|
53
|
+
description: z.string().trim().min(1).max(2000),
|
|
54
|
+
model: modelSchema.nullable().optional(),
|
|
55
|
+
variant: variantSchema.optional(),
|
|
56
|
+
agent: agentSchema.optional(),
|
|
57
|
+
}).strict();
|
|
58
|
+
|
|
59
|
+
const fileSchema = z.object({
|
|
60
|
+
version: z.literal(FILE_VERSION),
|
|
61
|
+
enabled: z.boolean().optional(),
|
|
62
|
+
fallback: z.object({ model: modelSchema, variant: variantSchema.optional() }).strict().nullable().optional(),
|
|
63
|
+
minConfidence: z.number().min(0).max(1).optional(),
|
|
64
|
+
safetyNet: z.object({ enabled: z.boolean(), threshold: z.number().min(0).max(1) }).strict().optional(),
|
|
65
|
+
categories: z.record(z.string().regex(CATEGORY_ID), z.discriminatedUnion('builtin', [builtinOverrideSchema, userCategorySchema])).optional(),
|
|
66
|
+
}).strict();
|
|
67
|
+
|
|
68
|
+
/** The effective category the runtime and the UI work with. */
|
|
69
|
+
const effectiveCategorySchema = z.object({
|
|
70
|
+
id: z.string().regex(CATEGORY_ID),
|
|
71
|
+
builtin: z.boolean(),
|
|
72
|
+
enabled: z.boolean(),
|
|
73
|
+
name: z.string().trim().min(1).max(60),
|
|
74
|
+
description: z.string().trim().min(1).max(2000),
|
|
75
|
+
model: modelSchema.nullable(),
|
|
76
|
+
variant: variantSchema,
|
|
77
|
+
agent: agentSchema,
|
|
78
|
+
}).strict();
|
|
79
|
+
|
|
80
|
+
const effectiveConfigSchema = z.object({
|
|
81
|
+
enabled: z.boolean(),
|
|
82
|
+
fallback: z.object({ model: modelSchema, variant: variantSchema }).strict().nullable(),
|
|
83
|
+
minConfidence: z.number().min(0).max(1),
|
|
84
|
+
safetyNet: z.object({ enabled: z.boolean(), threshold: z.number().min(0).max(1) }).strict(),
|
|
85
|
+
categories: z.array(effectiveCategorySchema).max(32),
|
|
86
|
+
}).strict();
|
|
87
|
+
|
|
88
|
+
const authSchema = z.object({ token: z.string().min(1).max(4000) }).strict();
|
|
89
|
+
|
|
90
|
+
const routingConfigPath = (dataDir) => path.join(dataDir, 'routing.json');
|
|
91
|
+
const routingAuthPath = (dataDir) => path.join(dataDir, 'routing-auth.json');
|
|
92
|
+
|
|
93
|
+
const readJsonFile = async (file, schema) => {
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = await fs.readFile(file, 'utf8');
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error?.code === 'ENOENT') return null;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
const text = raw.replace(/^/, '').trim();
|
|
102
|
+
if (text === '') return null;
|
|
103
|
+
const parsed = schema.safeParse(JSON.parse(text));
|
|
104
|
+
if (!parsed.success) throw new Error(`Invalid ${path.basename(file)}: ${parsed.error.issues[0]?.message ?? 'schema mismatch'}`);
|
|
105
|
+
return parsed.data;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const writeJsonFile = async (file, value, mode) => {
|
|
109
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
110
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
111
|
+
await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode });
|
|
112
|
+
await fs.rename(tmp, file);
|
|
113
|
+
if (mode) await fs.chmod(file, mode);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const emptyFile = () => ({ version: FILE_VERSION });
|
|
117
|
+
|
|
118
|
+
/** Built-ins plus the stored deviations, in built-in order followed by user categories. */
|
|
119
|
+
export const resolveEffectiveConfig = (stored) => {
|
|
120
|
+
const file = stored ?? emptyFile();
|
|
121
|
+
const overrides = file.categories ?? {};
|
|
122
|
+
const categories = [];
|
|
123
|
+
for (const builtin of BUILTIN_CATEGORIES) {
|
|
124
|
+
const override = overrides[builtin.id];
|
|
125
|
+
if (override && override.builtin !== true) continue; // a user id colliding with a built-in is ignored
|
|
126
|
+
if (override?.deleted) continue;
|
|
127
|
+
categories.push({
|
|
128
|
+
id: builtin.id,
|
|
129
|
+
builtin: true,
|
|
130
|
+
enabled: !(override?.disabled === true),
|
|
131
|
+
name: override?.name ?? builtin.name,
|
|
132
|
+
description: override?.description ?? builtin.description,
|
|
133
|
+
model: override?.model ?? null,
|
|
134
|
+
variant: override?.variant ?? null,
|
|
135
|
+
agent: override?.agent ?? null,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
for (const [id, entry] of Object.entries(overrides)) {
|
|
139
|
+
if (entry.builtin !== false) continue;
|
|
140
|
+
categories.push({
|
|
141
|
+
id,
|
|
142
|
+
builtin: false,
|
|
143
|
+
enabled: !(entry.disabled === true),
|
|
144
|
+
name: entry.name,
|
|
145
|
+
description: entry.description,
|
|
146
|
+
model: entry.model ?? null,
|
|
147
|
+
variant: entry.variant ?? null,
|
|
148
|
+
agent: entry.agent ?? null,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
enabled: file.enabled ?? false,
|
|
153
|
+
fallback: file.fallback ? { model: file.fallback.model, variant: file.fallback.variant ?? null } : null,
|
|
154
|
+
minConfidence: file.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
|
|
155
|
+
safetyNet: file.safetyNet ?? { enabled: false, threshold: DEFAULT_SAFETY_THRESHOLD },
|
|
156
|
+
categories,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** The stored shape for an effective config: built-ins keep only their deviations. */
|
|
161
|
+
export const toStoredConfig = (config) => {
|
|
162
|
+
const categories = {};
|
|
163
|
+
const builtinIds = new Set(BUILTIN_CATEGORIES.map((c) => c.id));
|
|
164
|
+
const present = new Set();
|
|
165
|
+
for (const category of config.categories) {
|
|
166
|
+
present.add(category.id);
|
|
167
|
+
if (category.builtin) {
|
|
168
|
+
const builtin = BUILTIN_CATEGORIES.find((c) => c.id === category.id);
|
|
169
|
+
if (!builtin) continue;
|
|
170
|
+
const override = { builtin: true };
|
|
171
|
+
if (!category.enabled) override.disabled = true;
|
|
172
|
+
if (category.name !== builtin.name) override.name = category.name;
|
|
173
|
+
if (category.description !== builtin.description) override.description = category.description;
|
|
174
|
+
if (category.model) override.model = category.model;
|
|
175
|
+
if (category.variant) override.variant = category.variant;
|
|
176
|
+
if (category.agent) override.agent = category.agent;
|
|
177
|
+
if (Object.keys(override).length > 1) categories[category.id] = override;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (builtinIds.has(category.id)) continue;
|
|
181
|
+
const entry = { builtin: false, name: category.name, description: category.description };
|
|
182
|
+
if (!category.enabled) entry.disabled = true;
|
|
183
|
+
if (category.model) entry.model = category.model;
|
|
184
|
+
if (category.variant) entry.variant = category.variant;
|
|
185
|
+
if (category.agent) entry.agent = category.agent;
|
|
186
|
+
categories[category.id] = entry;
|
|
187
|
+
}
|
|
188
|
+
for (const builtin of BUILTIN_CATEGORIES) {
|
|
189
|
+
if (!present.has(builtin.id)) categories[builtin.id] = { builtin: true, deleted: true };
|
|
190
|
+
}
|
|
191
|
+
const stored = { version: FILE_VERSION, enabled: config.enabled, minConfidence: config.minConfidence, safetyNet: config.safetyNet };
|
|
192
|
+
if (config.fallback) {
|
|
193
|
+
stored.fallback = { model: config.fallback.model };
|
|
194
|
+
if (config.fallback.variant) stored.fallback.variant = config.fallback.variant;
|
|
195
|
+
}
|
|
196
|
+
if (Object.keys(categories).length > 0) stored.categories = categories;
|
|
197
|
+
return stored;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Parses an untrusted effective config (a PUT body) into the trusted shape, or throws. */
|
|
201
|
+
export const parseEffectiveConfig = (input) => {
|
|
202
|
+
const parsed = effectiveConfigSchema.safeParse(input);
|
|
203
|
+
if (!parsed.success) {
|
|
204
|
+
const issue = parsed.error.issues[0];
|
|
205
|
+
throw Object.assign(new Error(`Invalid routing config at ${issue?.path.join('.') || 'root'}: ${issue?.message ?? 'schema mismatch'}`), { status: 400 });
|
|
206
|
+
}
|
|
207
|
+
const config = parsed.data;
|
|
208
|
+
const ids = new Set();
|
|
209
|
+
for (const category of config.categories) {
|
|
210
|
+
if (ids.has(category.id)) throw Object.assign(new Error(`Duplicate category id ${category.id}`), { status: 400 });
|
|
211
|
+
ids.add(category.id);
|
|
212
|
+
if (category.variant && !THINKING_LEVELS.includes(category.variant) && !/^[\w.-]+$/.test(category.variant)) {
|
|
213
|
+
throw Object.assign(new Error(`Invalid variant for ${category.id}`), { status: 400 });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return config;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const createRoutingStore = ({ dataDir }) => {
|
|
220
|
+
const configFile = routingConfigPath(dataDir);
|
|
221
|
+
const authFile = routingAuthPath(dataDir);
|
|
222
|
+
let writeChain = Promise.resolve();
|
|
223
|
+
const serialize = (run) => {
|
|
224
|
+
const next = writeChain.catch(() => undefined).then(run);
|
|
225
|
+
writeChain = next;
|
|
226
|
+
return next;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
/** Effective config; a missing file is the defaults, a broken file throws. */
|
|
231
|
+
readConfig: async () => resolveEffectiveConfig(await readJsonFile(configFile, fileSchema)),
|
|
232
|
+
writeConfig: (config) => serialize(async () => {
|
|
233
|
+
await writeJsonFile(configFile, toStoredConfig(config));
|
|
234
|
+
return config;
|
|
235
|
+
}),
|
|
236
|
+
readToken: async () => {
|
|
237
|
+
try {
|
|
238
|
+
return (await readJsonFile(authFile, authSchema))?.token ?? null;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.warn('[routing] routing-auth.json is unreadable:', error?.message ?? error);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
writeToken: (token) => serialize(() => writeJsonFile(authFile, { token }, 0o600)),
|
|
245
|
+
clearToken: () => serialize(async () => {
|
|
246
|
+
await fs.rm(authFile, { force: true });
|
|
247
|
+
}),
|
|
248
|
+
};
|
|
249
|
+
};
|