synabun 2.0.0 → 2.0.5
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/CHANGELOG.md +57 -0
- package/NOTICE +5 -0
- package/README.md +1 -1
- package/THIRD-PARTY-LICENSES.md +46 -0
- package/benchmarks/fixtures/browser/README.md +1 -1
- package/benchmarks/fixtures/desktop/README.md +1 -1
- package/docs/whatsapp.md +2 -2
- package/hooks/claude-code/pre-compact.mjs +11 -0
- package/hooks/claude-code/prompt-submit.mjs +60 -184
- package/hooks/claude-code/session-start.mjs +73 -16
- package/hooks/claude-code/shared.mjs +20 -0
- package/hooks/claude-code/stop.mjs +5 -5
- package/lib/start-launcher.js +88 -1
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -3
- package/neural-interface/i18n/en.json +1500 -153
- package/neural-interface/i18n/pt-BR.json +1498 -152
- package/neural-interface/lib/assistant-api.js +62 -6
- package/neural-interface/lib/assistant-brains/claude.js +4 -0
- package/neural-interface/lib/assistant-catalog.js +179 -27
- package/neural-interface/lib/assistant-config.js +31 -2
- package/neural-interface/lib/assistant-dispatch.js +62 -13
- package/neural-interface/lib/assistant-hidden-models.js +393 -66
- package/neural-interface/lib/assistant-memory.js +24 -10
- package/neural-interface/lib/assistant-persona.js +6 -2
- package/neural-interface/lib/assistant-pricing.js +21 -6
- package/neural-interface/lib/assistant-runtime.js +181 -12
- package/neural-interface/lib/assistant-task-prompt.js +13 -3
- package/neural-interface/lib/assistant-test-density.js +142 -0
- package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
- package/neural-interface/lib/assistant-user-context.js +908 -0
- package/neural-interface/lib/assistant-user-knowledge.js +328 -0
- package/neural-interface/lib/assistant-user-learn.js +404 -0
- package/neural-interface/lib/assistant-user-threads.js +226 -0
- package/neural-interface/lib/assistant-user-writer.js +181 -0
- package/neural-interface/lib/browser-background-tab.js +134 -0
- package/neural-interface/lib/browser-screenshot.js +678 -61
- package/neural-interface/lib/claude-agent-bridge.js +33 -6
- package/neural-interface/lib/claude-model-catalog.js +30 -2
- package/neural-interface/lib/claude-temporary.js +46 -7
- package/neural-interface/lib/cloudflared/install.js +533 -0
- package/neural-interface/lib/cloudflared/manager.js +545 -0
- package/neural-interface/lib/cloudflared/pins.js +115 -0
- package/neural-interface/lib/cloudflared/pins.json +63 -0
- package/neural-interface/lib/cloudflared/tunnel.js +265 -0
- package/neural-interface/lib/editor-completion-endpoint.js +264 -0
- package/neural-interface/lib/editor-completion-opencode.js +436 -0
- package/neural-interface/lib/editor-completion.js +714 -0
- package/neural-interface/lib/editor-config.js +149 -0
- package/neural-interface/lib/editor-files-api.js +268 -0
- package/neural-interface/lib/editor-git-api.js +134 -0
- package/neural-interface/lib/file-explorer-api.js +1039 -0
- package/neural-interface/lib/file-explorer-system.js +355 -0
- package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
- package/neural-interface/lib/file-explorer-watch.js +239 -0
- package/neural-interface/lib/git-diff-api.js +103 -0
- package/neural-interface/lib/git-read.js +202 -0
- package/neural-interface/lib/greeting/api.js +89 -0
- package/neural-interface/lib/greeting/budgets.js +90 -0
- package/neural-interface/lib/greeting/engine.js +461 -0
- package/neural-interface/lib/greeting/facts.js +320 -0
- package/neural-interface/lib/greeting/sources.js +252 -0
- package/neural-interface/lib/greeting/writer.js +139 -0
- package/neural-interface/lib/http-guards.js +189 -13
- package/neural-interface/lib/machine-names.js +88 -0
- package/neural-interface/lib/memory-api.js +31 -7
- package/neural-interface/lib/native-loop-providers.js +10 -5
- package/neural-interface/lib/plugin-installer.js +4 -2
- package/neural-interface/lib/project-storage.js +5 -7
- package/neural-interface/lib/request-gate.js +589 -0
- package/neural-interface/lib/ui-blob-api.js +108 -0
- package/neural-interface/lib/ui-blob-store.js +68 -0
- package/neural-interface/package-lock.json +701 -2
- package/neural-interface/package.json +13 -2
- package/neural-interface/public/install-app.html +9 -6
- package/neural-interface/public/offline.html +3 -2
- package/neural-interface/public/onboarding.html +12 -1
- package/neural-interface/public/shared/agent-runtime-options.js +22 -6
- package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +312 -62
- package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
- package/neural-interface/public/shared/assistant/asst-mascot.js +18 -58
- package/neural-interface/public/shared/assistant/asst-menu.js +12 -8
- package/neural-interface/public/shared/assistant/asst-panel.js +150 -28
- package/neural-interface/public/shared/assistant/asst-render.js +162 -19
- package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
- package/neural-interface/public/shared/assistant/asst-route.js +23 -329
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +495 -0
- package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
- package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
- package/neural-interface/public/shared/assistant/asst-state.js +185 -1
- package/neural-interface/public/shared/assistant/asst-styles.js +158 -4
- package/neural-interface/public/shared/blob-store.js +111 -22
- package/neural-interface/public/shared/cdx/cdx-panel.js +32 -11
- package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
- package/neural-interface/public/shared/cdx/cdx-styles.js +36 -0
- package/neural-interface/public/shared/cdx/cdx-tabs.js +87 -4
- package/neural-interface/public/shared/constants.js +6 -1
- package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
- package/neural-interface/public/shared/cp/cp-styles.js +15 -0
- package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
- package/neural-interface/public/shared/editor/editor-ai.js +619 -0
- package/neural-interface/public/shared/editor/editor-commands.js +212 -0
- package/neural-interface/public/shared/editor/editor-compare.js +103 -0
- package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
- package/neural-interface/public/shared/editor/editor-doc.js +257 -0
- package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
- package/neural-interface/public/shared/editor/editor-git.js +95 -0
- package/neural-interface/public/shared/editor/editor-languages.js +156 -0
- package/neural-interface/public/shared/editor/editor-lint.js +229 -0
- package/neural-interface/public/shared/editor/editor-loader.js +51 -0
- package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
- package/neural-interface/public/shared/editor/editor-menu.js +233 -0
- package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
- package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
- package/neural-interface/public/shared/editor/editor-palette.js +125 -0
- package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
- package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
- package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
- package/neural-interface/public/shared/editor/editor-safety.js +500 -0
- package/neural-interface/public/shared/editor/editor-search.js +352 -0
- package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
- package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
- package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
- package/neural-interface/public/shared/editor/editor-theme.js +244 -0
- package/neural-interface/public/shared/editor/editor-view.js +332 -0
- package/neural-interface/public/shared/explorer/fx-api.js +216 -0
- package/neural-interface/public/shared/explorer/fx-links.js +204 -0
- package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
- package/neural-interface/public/shared/explorer/fx-main.js +865 -0
- package/neural-interface/public/shared/explorer/fx-names.js +87 -0
- package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
- package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
- package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
- package/neural-interface/public/shared/file-links.js +339 -0
- package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
- package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
- package/neural-interface/public/shared/html-shell.js +160 -165
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +99 -17
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +5 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +40 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
- package/neural-interface/public/shared/pwa-install-page.js +29 -1
- package/neural-interface/public/shared/pwa-install.css +3 -3
- package/neural-interface/public/shared/pwa-install.js +100 -1
- package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
- package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
- package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
- package/neural-interface/public/shared/settings/settings-ia.js +18 -4
- package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
- package/neural-interface/public/shared/start-bridge.js +238 -19
- package/neural-interface/public/shared/styles.css +1414 -721
- package/neural-interface/public/shared/synabun-mascot.js +20 -56
- package/neural-interface/public/shared/tunnel-view.js +167 -0
- package/neural-interface/public/shared/ui-claude-panel.js +157 -16
- package/neural-interface/public/shared/ui-detail.js +15 -0
- package/neural-interface/public/shared/ui-explorer.js +22 -0
- package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
- package/neural-interface/public/shared/ui-file-links.js +103 -0
- package/neural-interface/public/shared/ui-help.js +26 -0
- package/neural-interface/public/shared/ui-invite.js +126 -116
- package/neural-interface/public/shared/ui-keybinds.js +4 -0
- package/neural-interface/public/shared/ui-loading.js +159 -15
- package/neural-interface/public/shared/ui-menubar.js +12 -30
- package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
- package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
- package/neural-interface/public/shared/ui-navbar.js +45 -21
- package/neural-interface/public/shared/ui-settings.js +455 -488
- package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
- package/neural-interface/public/shared/ui-sync.js +16 -0
- package/neural-interface/public/shared/ui-terminal.js +23 -5
- package/neural-interface/public/shared/ui-tunnel.js +130 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
- package/neural-interface/public/shared/ui-whiteboard.js +12 -0
- package/neural-interface/public/shared/ui-worker-end.js +245 -0
- package/neural-interface/public/shared/ui-workspaces.js +41 -27
- package/neural-interface/public/shared/worker-end.js +251 -0
- package/neural-interface/public/sw.js +2 -2
- package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
- package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
- package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
- package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
- package/neural-interface/scripts/build-vendor.mjs +78 -2
- package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
- package/neural-interface/server.js +873 -1028
- package/neural-interface/templates/rulesets/README.md +1 -1
- package/package.json +2 -8
- package/setup.js +44 -7
- package/benchmarks/browser-live-results.json +0 -79
- package/benchmarks/browser-v2-results.json +0 -258
- package/neural-interface/lib/codex-capabilities.d.ts +0 -41
- package/neural-interface/public/category-logos/claude.svg +0 -1
- package/neural-interface/public/category-logos/gemini-color.svg +0 -1
- package/neural-interface/public/category-logos/openai.svg +0 -1
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude.svg +0 -1
- package/neural-interface/public/gallery.svg +0 -6
- package/neural-interface/public/leonardoai.svg +0 -1
- package/neural-interface/public/mcp.svg +0 -4
- package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
- package/neural-interface/public/pin-svgrepo-com.svg +0 -25
- package/neural-interface/public/sessionicon.svg +0 -8
- package/neural-interface/public/toolbar-album.svg +0 -8
- package/neural-interface/public/toolbar-question.svg +0 -6
- package/neural-interface/public/toolbar-sessions.svg +0 -6
- package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
- package/skills/synabun/synabunicon.png +0 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
// ── The code editor's AI autocomplete: the service and its routes ──
|
|
2
|
+
// The File Explorer's editor asks for the text to insert at the caret
|
|
3
|
+
// (POST /api/editor/complete); this module decides whether the request is
|
|
4
|
+
// answered at all, what the model is told, and what of its reply is fit to
|
|
5
|
+
// show. Two engines do the asking: OpenCode models through a dedicated serve
|
|
6
|
+
// (lib/editor-completion-opencode.js) and a user-configured Ollama or
|
|
7
|
+
// OpenAI-compatible endpoint (lib/editor-completion-endpoint.js).
|
|
8
|
+
//
|
|
9
|
+
// Nothing is sent while `ai.enabled` is off, for a file whose name matches
|
|
10
|
+
// `ai.exclude`, or when the text around the caret holds a private key: the
|
|
11
|
+
// same rules the page applies (public/shared/editor/editor-ai-logic.js),
|
|
12
|
+
// checked again here against the stored preferences. A request that the page
|
|
13
|
+
// gives up on is aborted at the engine. Dependencies are injected: the service
|
|
14
|
+
// is tested with fake engines, never against a model.
|
|
15
|
+
//
|
|
16
|
+
// POST /api/editor/complete { path, language, indentUnit, prefix, suffix, header?, neighbors?, trigger }
|
|
17
|
+
// 200 { ok, completion, ms, model, tokens? } · 400 { code: 'invalid' } · 403 { code: 'excluded' }
|
|
18
|
+
// 409 { code: 'disabled' } · 429 { code: 'rate', retryAfterMs } · 502 { code: 'engine', message, reason? }
|
|
19
|
+
// POST /api/editor/complete/test { engine?, model?, fastest? } → { ok, results: [{ model, ok, ms, tokens, sample }], fastest }
|
|
20
|
+
// a row that could not be asked says why: { ok: false, error, reason: 'rate', retryAfterMs } once the minute's
|
|
21
|
+
// requests are used up (a test, "Find fastest" and its warm-up runs count like completions)
|
|
22
|
+
// GET /api/editor/complete/models → { ok, available, models: [{ id, label, provider, price, free, fast, context }] }
|
|
23
|
+
// GET /api/editor/complete/usage → { ok, today: { day, requests, shown, accepted, tokensIn, tokensOut } }
|
|
24
|
+
// POST /api/editor/complete/feedback { shown?, accepted? } → { ok }
|
|
25
|
+
|
|
26
|
+
import { Router } from 'express';
|
|
27
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { dirname } from 'node:path';
|
|
29
|
+
import { replaceFileAtomic } from './confined-fs.js';
|
|
30
|
+
import { parseOpenCodeProviders } from './assistant-catalog.js';
|
|
31
|
+
import { asEndpointFailure, redactKey } from './editor-completion-endpoint.js';
|
|
32
|
+
import {
|
|
33
|
+
AI_LIMITS, clampHeader, clampPrefix, clampSuffix, hasPrivateKey, matchesExclude,
|
|
34
|
+
} from '../public/shared/editor/editor-ai-logic.js';
|
|
35
|
+
|
|
36
|
+
/** What these routes add to the editor (GET /api/editor/capabilities). */
|
|
37
|
+
export const EDITOR_COMPLETION_CAPABILITIES = Object.freeze(['complete']);
|
|
38
|
+
export const RATE_LIMIT_PER_MINUTE = 60;
|
|
39
|
+
export const ENGINE_TIMEOUT_MS = 6000;
|
|
40
|
+
/** How many models "Find fastest" tries. */
|
|
41
|
+
export const FASTEST_CANDIDATES = 4;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a chat model is told. It is the dedicated OpenCode serve's agent prompt and the endpoint's system message,
|
|
45
|
+
* the same bytes in every request: a provider that caches the start of a prompt reuses it.
|
|
46
|
+
*/
|
|
47
|
+
export const COMPLETION_SYSTEM = 'You complete code at the cursor. Reply with only the text to insert, inside <COMPLETION> tags. No explanation, no code fences, nothing that already appears before or after the cursor.';
|
|
48
|
+
|
|
49
|
+
const CURSOR = '<CURSOR/>';
|
|
50
|
+
const CURSOR_ALTERNATIVES = ['<CURSOR/>', '<CARET_HERE/>', '<<<CARET>>>'];
|
|
51
|
+
|
|
52
|
+
// ── The request ──
|
|
53
|
+
|
|
54
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
55
|
+
const cleanLine = (v, max) => String(v ?? '').replace(/[\r\n\t]+/g, ' ').trim().slice(0, max);
|
|
56
|
+
const unixBreaks = (text) => text.replace(/\r\n?/g, '\n');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A request as the service uses it, from whatever the page sent: texts cut to their limits (the prefix from a line
|
|
60
|
+
* start, the suffix to a line end), line breaks as "\n".
|
|
61
|
+
* @returns {{ ok: true, request: object } | { ok: false, error: string }}
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeCompletionRequest(body) {
|
|
64
|
+
if (!isObject(body)) return { ok: false, error: 'The body must be an object' };
|
|
65
|
+
const path = typeof body.path === 'string' ? body.path.trim() : '';
|
|
66
|
+
if (!path || path.length > AI_LIMITS.pathChars || path.includes('\0')) return { ok: false, error: 'path must be the path of the file being edited' };
|
|
67
|
+
if (typeof body.prefix !== 'string') return { ok: false, error: 'prefix must be the text before the caret' };
|
|
68
|
+
if (body.suffix !== undefined && typeof body.suffix !== 'string') return { ok: false, error: 'suffix must be the text after the caret' };
|
|
69
|
+
if (body.header !== undefined && typeof body.header !== 'string') return { ok: false, error: 'header must be text' };
|
|
70
|
+
if (body.neighbors !== undefined && !Array.isArray(body.neighbors)) return { ok: false, error: 'neighbors must be a list' };
|
|
71
|
+
const neighbors = [];
|
|
72
|
+
for (const item of body.neighbors || []) {
|
|
73
|
+
if (!isObject(item) || typeof item.path !== 'string' || typeof item.text !== 'string') return { ok: false, error: 'a neighbor is { path, text }' };
|
|
74
|
+
if (neighbors.length < AI_LIMITS.neighborCount && item.text.trim()) {
|
|
75
|
+
neighbors.push({ path: item.path.slice(0, AI_LIMITS.pathChars), text: unixBreaks(item.text).slice(0, AI_LIMITS.neighborChars) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const indentUnit = typeof body.indentUnit === 'string' && /^(?: {1,8}|\t)$/.test(body.indentUnit) ? body.indentUnit : ' ';
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
request: {
|
|
82
|
+
path,
|
|
83
|
+
language: cleanLine(body.language, 60),
|
|
84
|
+
indentUnit,
|
|
85
|
+
prefix: clampPrefix(unixBreaks(body.prefix)),
|
|
86
|
+
suffix: clampSuffix(unixBreaks(body.suffix || '')),
|
|
87
|
+
header: clampHeader(unixBreaks(body.header || '')),
|
|
88
|
+
neighbors,
|
|
89
|
+
trigger: body.trigger === 'manual' ? 'manual' : 'auto',
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Why a request is not sent to any model, or null when it may be.
|
|
96
|
+
* @param {object} request from normalizeCompletionRequest()
|
|
97
|
+
* @param {string[]} exclude `ai.exclude`
|
|
98
|
+
* @returns {null | { reason: 'file' | 'private-key' }}
|
|
99
|
+
*/
|
|
100
|
+
export function refusal(request, exclude) {
|
|
101
|
+
if (matchesExclude(request.path, exclude)) return { reason: 'file' };
|
|
102
|
+
if (request.neighbors.some((n) => matchesExclude(n.path, exclude))) return { reason: 'file' };
|
|
103
|
+
if (hasPrivateKey(request.prefix, request.suffix, request.header, ...request.neighbors.map((n) => n.text))) return { reason: 'private-key' };
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── The prompt ──
|
|
108
|
+
|
|
109
|
+
/** The path a model is shown: the file's name and the folder it is in, never the folders above. */
|
|
110
|
+
export function promptPath(path) {
|
|
111
|
+
return String(path ?? '').replace(/\\/g, '/').split('/').filter(Boolean).slice(-2).join('/');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const describeIndent = (unit) => (unit === '\t' ? 'tabs' : `${unit.length} spaces`);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The messages of a chat model: the fixed system message, and one user message with the file's path and language,
|
|
118
|
+
* the start of the file and snippets of other files when there are any, then the text around the caret with a
|
|
119
|
+
* marker where the caret is.
|
|
120
|
+
* @param {object} request from normalizeCompletionRequest()
|
|
121
|
+
* @returns {{ system: string, user: string, marker: string }}
|
|
122
|
+
*/
|
|
123
|
+
export function buildChatPrompt(request) {
|
|
124
|
+
const around = `${request.prefix}${request.suffix}`;
|
|
125
|
+
const marker = CURSOR_ALTERNATIVES.find((m) => !around.includes(m)) || CURSOR;
|
|
126
|
+
const lines = [`File: ${promptPath(request.path)}`];
|
|
127
|
+
if (request.language) lines.push(`Language: ${request.language}`);
|
|
128
|
+
lines.push(`Indentation: ${describeIndent(request.indentUnit)}`);
|
|
129
|
+
if (request.header) lines.push('', 'The file begins with:', request.header);
|
|
130
|
+
for (const neighbor of request.neighbors) lines.push('', `From another open file, ${promptPath(neighbor.path)}:`, neighbor.text);
|
|
131
|
+
lines.push('', `Complete the code at ${marker}:`, `${request.prefix}${marker}${request.suffix}`);
|
|
132
|
+
return { system: COMPLETION_SYSTEM, user: lines.join('\n'), marker };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── The reply ──
|
|
136
|
+
|
|
137
|
+
const OPEN_TAG = /<COMPLETION>/i;
|
|
138
|
+
const CLOSE_TAG = /<\/COMPLETION>/i;
|
|
139
|
+
const FENCE_BLOCK = /```[^\n`]*\n([\s\S]*?)(?:\n?```|$)/; // ```js ⏎ … ⏎ ```, closed or cut short
|
|
140
|
+
const FENCE_INLINE = /```([^`]+)```/; // ```…``` on one line
|
|
141
|
+
|
|
142
|
+
/** The answer inside a reply: the body of its <COMPLETION> tags, else its first fenced block, else the reply itself. */
|
|
143
|
+
export function extractCompletion(raw) {
|
|
144
|
+
let text = String(raw ?? '');
|
|
145
|
+
const open = OPEN_TAG.exec(text);
|
|
146
|
+
let tagged = false;
|
|
147
|
+
if (open) {
|
|
148
|
+
tagged = true;
|
|
149
|
+
text = text.slice(open.index + open[0].length);
|
|
150
|
+
const close = CLOSE_TAG.exec(text);
|
|
151
|
+
if (close) text = text.slice(0, close.index);
|
|
152
|
+
}
|
|
153
|
+
// A fence around the answer: always when there are no tags, and inside the tags only when it wraps all of it.
|
|
154
|
+
if (tagged ? /^\s*```/.test(text) : text.includes('```')) {
|
|
155
|
+
const fenced = FENCE_BLOCK.exec(text) || FENCE_INLINE.exec(text);
|
|
156
|
+
if (fenced) text = fenced[1];
|
|
157
|
+
}
|
|
158
|
+
return text.replace(/<\/?COMPLETION>/gi, '');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const WORD_BEGUN = /[\p{L}_$][\p{L}\p{N}_$]*$/u;
|
|
162
|
+
|
|
163
|
+
function dropLeadingRepeat(text, linePrefix) {
|
|
164
|
+
if (!linePrefix) return text;
|
|
165
|
+
if (text.startsWith(linePrefix)) return text.slice(linePrefix.length);
|
|
166
|
+
const written = linePrefix.trimStart();
|
|
167
|
+
if (written) {
|
|
168
|
+
if (text.startsWith(written)) return text.slice(written.length);
|
|
169
|
+
// A word that was begun, written out from its first letter again ("ret" + "return x" is "return x").
|
|
170
|
+
const begun = WORD_BEGUN.exec(written)?.[0];
|
|
171
|
+
return begun && text.startsWith(begun) ? text.slice(begun.length) : text;
|
|
172
|
+
}
|
|
173
|
+
// Only indentation so far: the model's own indentation of its first line is the same indentation again.
|
|
174
|
+
const indent = /^[ \t]*/.exec(text)[0];
|
|
175
|
+
return text.slice(Math.min(indent.length, linePrefix.length));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const CLOSERS = { ')': '(', ']': '[', '}': '{' };
|
|
179
|
+
const QUOTES = '\'"`';
|
|
180
|
+
|
|
181
|
+
/** How many brackets a text opens and does not close (brackets inside its strings are text). */
|
|
182
|
+
function unclosedBrackets(text) {
|
|
183
|
+
const open = [];
|
|
184
|
+
let quote = '';
|
|
185
|
+
for (let i = 0; i < text.length; i++) {
|
|
186
|
+
const ch = text[i];
|
|
187
|
+
if (quote) {
|
|
188
|
+
if (ch === '\\') i++;
|
|
189
|
+
else if (ch === quote || (ch === '\n' && quote !== '`')) quote = '';
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (QUOTES.includes(ch)) quote = ch;
|
|
193
|
+
else if (ch === '(' || ch === '[' || ch === '{') open.push(ch);
|
|
194
|
+
else if (CLOSERS[ch] && open[open.length - 1] === CLOSERS[ch]) open.pop();
|
|
195
|
+
}
|
|
196
|
+
return open.length;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function cutTail(text, suffix) {
|
|
200
|
+
const max = Math.min(text.length, suffix.length);
|
|
201
|
+
for (let k = max; k > 0; k--) {
|
|
202
|
+
const tail = suffix.slice(0, k);
|
|
203
|
+
if (!text.endsWith(tail)) continue;
|
|
204
|
+
// one letter or digit is too little to call a repeat of what follows
|
|
205
|
+
if (k === 1 && /[\p{L}\p{N}_]/u.test(tail)) return text;
|
|
206
|
+
const kept = text.slice(0, text.length - k);
|
|
207
|
+
// A bracket that closes one the reply opened is the reply's own, however much it looks like what follows.
|
|
208
|
+
if (/[)\]}]/.test(tail) && unclosedBrackets(kept) > 0) continue;
|
|
209
|
+
return kept;
|
|
210
|
+
}
|
|
211
|
+
return text;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `text` without a trailing run that repeats the start of `suffix`. */
|
|
215
|
+
function cutSuffixOverlap(text, suffix) {
|
|
216
|
+
if (!text || !suffix) return text;
|
|
217
|
+
const cut = cutTail(text, suffix);
|
|
218
|
+
if (cut !== text) return cut;
|
|
219
|
+
const trimmed = text.replace(/\s+$/, '');
|
|
220
|
+
return trimmed === text ? text : cutTail(trimmed, suffix);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One line of a reply, cut where it starts to repeat the closing punctuation that already follows the caret: at
|
|
225
|
+
* a closing bracket the line itself never opened, or at the closing quote of a string it is inside of.
|
|
226
|
+
*/
|
|
227
|
+
function cutAtExistingCloser(line, rest) {
|
|
228
|
+
const closer = rest[0];
|
|
229
|
+
if (!closer) return line;
|
|
230
|
+
if (QUOTES.includes(closer)) {
|
|
231
|
+
const at = [];
|
|
232
|
+
for (let i = 0; i < line.length; i++) {
|
|
233
|
+
if (line[i] === '\\') { i++; continue; }
|
|
234
|
+
if (line[i] === closer) at.push(i);
|
|
235
|
+
}
|
|
236
|
+
return at.length % 2 === 1 ? line.slice(0, at[at.length - 1]) : line;
|
|
237
|
+
}
|
|
238
|
+
if (!CLOSERS[closer]) return line;
|
|
239
|
+
const open = [];
|
|
240
|
+
let quote = '';
|
|
241
|
+
for (let i = 0; i < line.length; i++) {
|
|
242
|
+
const ch = line[i];
|
|
243
|
+
if (quote) {
|
|
244
|
+
if (ch === '\\') i++;
|
|
245
|
+
else if (ch === quote) quote = '';
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (QUOTES.includes(ch)) { quote = ch; continue; }
|
|
249
|
+
if (ch === '(' || ch === '[' || ch === '{') { open.push(ch); continue; }
|
|
250
|
+
if (!CLOSERS[ch]) continue;
|
|
251
|
+
if (open.length && open[open.length - 1] === CLOSERS[ch]) { open.pop(); continue; }
|
|
252
|
+
if (ch === closer) return line.slice(0, i);
|
|
253
|
+
}
|
|
254
|
+
return line;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function capChars(text, max) {
|
|
258
|
+
if (text.length <= max) return text;
|
|
259
|
+
const cut = text.slice(0, max);
|
|
260
|
+
const lineEnd = cut.lastIndexOf('\n');
|
|
261
|
+
return lineEnd > 0 ? cut.slice(0, lineEnd) : cut;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* What of a model's reply is shown as the suggestion. In order: the <COMPLETION> body (else the reply without its
|
|
266
|
+
* code fence); line breaks as "\n"; a leading repeat of the caret's line dropped; a trailing run that repeats what
|
|
267
|
+
* follows the caret cut; one line only when the caret is in the middle of a line; at most `maxLines` lines and
|
|
268
|
+
* 1,200 characters; and nothing at all when only white space is left.
|
|
269
|
+
* @param {string} raw the model's reply
|
|
270
|
+
* @param {{ prefix?: string, suffix?: string, maxLines?: number }} around the request's texts, and `ai.maxLines`
|
|
271
|
+
* @returns {string}
|
|
272
|
+
*/
|
|
273
|
+
export function cleanCompletion(raw, { prefix = '', suffix = '', maxLines = 12 } = {}) {
|
|
274
|
+
let text = unixBreaks(extractCompletion(raw));
|
|
275
|
+
for (const marker of CURSOR_ALTERNATIVES) text = text.split(marker).join('');
|
|
276
|
+
const linePrefix = prefix.slice(prefix.lastIndexOf('\n') + 1);
|
|
277
|
+
const lineEnd = suffix.indexOf('\n');
|
|
278
|
+
const lineSuffix = lineEnd < 0 ? suffix : suffix.slice(0, lineEnd);
|
|
279
|
+
const midLine = lineSuffix.trim() !== '';
|
|
280
|
+
|
|
281
|
+
// An empty line being filled: the answer starts on it, not on the line below.
|
|
282
|
+
if (!linePrefix.trim()) text = text.replace(/^(?:[ \t]*\n)+/, '');
|
|
283
|
+
text = dropLeadingRepeat(text, linePrefix);
|
|
284
|
+
// After text that already ends in a space, a reply that begins with more of them would only double it.
|
|
285
|
+
if (/\S[ \t]+$/.test(linePrefix)) text = text.replace(/^[ \t]+/, '');
|
|
286
|
+
text = cutSuffixOverlap(text, suffix);
|
|
287
|
+
if (midLine) {
|
|
288
|
+
text = text.replace(/^\n+/, '').split('\n')[0];
|
|
289
|
+
text = cutSuffixOverlap(text, lineSuffix);
|
|
290
|
+
text = cutAtExistingCloser(text, lineSuffix.trimStart());
|
|
291
|
+
}
|
|
292
|
+
const lines = Math.max(1, Math.round(Number(maxLines) || 12));
|
|
293
|
+
text = text.split('\n').slice(0, lines).join('\n');
|
|
294
|
+
text = capChars(text, AI_LIMITS.completionChars);
|
|
295
|
+
text = text.replace(/\s+$/, '');
|
|
296
|
+
return text.trim() ? text : '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Whether a reply that is still arriving already holds all that would be shown, so the stream can be closed.
|
|
301
|
+
* @param {string} raw what has arrived
|
|
302
|
+
* @param {{ chat?: boolean, suffix?: string, maxLines?: number }} o `chat`: the reply is a chat model's
|
|
303
|
+
*/
|
|
304
|
+
export function replyIsComplete(raw, { chat = false, suffix = '', maxLines = 12 } = {}) {
|
|
305
|
+
const text = String(raw ?? '');
|
|
306
|
+
let body = text;
|
|
307
|
+
if (chat) {
|
|
308
|
+
const open = OPEN_TAG.exec(text);
|
|
309
|
+
if (open) {
|
|
310
|
+
body = text.slice(open.index + open[0].length);
|
|
311
|
+
if (CLOSE_TAG.test(body)) return true;
|
|
312
|
+
} else {
|
|
313
|
+
// no tags yet: a fenced block that has closed is an answer; otherwise wait, up to a limit
|
|
314
|
+
if ((text.match(/```/g) || []).length >= 2) return true;
|
|
315
|
+
return text.length > 8000;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (body.length > AI_LIMITS.completionChars + 400) return true;
|
|
319
|
+
if (body.split('\n').length > Math.max(1, maxLines) + 2) return true;
|
|
320
|
+
const lineEnd = suffix.indexOf('\n');
|
|
321
|
+
const midLine = (lineEnd < 0 ? suffix : suffix.slice(0, lineEnd)).trim() !== '';
|
|
322
|
+
return midLine && /\S[^\n]*\n/.test(body);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Limits and counters ──
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* At most `limit` requests in any `windowMs`.
|
|
329
|
+
* @returns {{ take: () => { ok: true } | { ok: false, retryAfterMs: number }, count: () => number }}
|
|
330
|
+
*/
|
|
331
|
+
export function createRateLimiter({ limit = RATE_LIMIT_PER_MINUTE, windowMs = 60_000, now = Date.now } = {}) {
|
|
332
|
+
let taken = [];
|
|
333
|
+
const live = () => { const from = now() - windowMs; taken = taken.filter((at) => at > from); return taken; };
|
|
334
|
+
return {
|
|
335
|
+
take() {
|
|
336
|
+
const at = now();
|
|
337
|
+
if (live().length >= limit) return { ok: false, retryAfterMs: Math.max(1, taken[0] + windowMs - at) };
|
|
338
|
+
taken.push(at);
|
|
339
|
+
return { ok: true };
|
|
340
|
+
},
|
|
341
|
+
count: () => live().length,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const USAGE_FIELDS = ['requests', 'shown', 'accepted', 'tokensIn', 'tokensOut'];
|
|
346
|
+
const dayOf = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The counters of AI autocomplete by day (DATA_HOME/data/editor-ai-usage.json): requests sent to a model,
|
|
350
|
+
* suggestions shown and accepted, tokens in and out. Written a moment after a change, and at shutdown.
|
|
351
|
+
* @param {{ path: string, now?: () => number, keepDays?: number, flushMs?: number, log?: (tag: string, message: string) => void }} o
|
|
352
|
+
*/
|
|
353
|
+
export function createUsageStore({ path, now = Date.now, keepDays = 60, flushMs = 2000, log = () => {} } = {}) {
|
|
354
|
+
if (!path) throw new Error('createUsageStore requires path');
|
|
355
|
+
let days = null;
|
|
356
|
+
let timer = null;
|
|
357
|
+
let dirty = false;
|
|
358
|
+
|
|
359
|
+
function load() {
|
|
360
|
+
if (days) return days;
|
|
361
|
+
days = {};
|
|
362
|
+
try {
|
|
363
|
+
const stored = JSON.parse(readFileSync(path, 'utf8'))?.days;
|
|
364
|
+
if (isObject(stored)) {
|
|
365
|
+
for (const [day, row] of Object.entries(stored)) {
|
|
366
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !isObject(row)) continue;
|
|
367
|
+
days[day] = Object.fromEntries(USAGE_FIELDS.map((f) => [f, Math.max(0, Math.round(Number(row[f]) || 0))]));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch (error) { if (error?.code !== 'ENOENT') log('editor-ai-usage:unreadable', error?.message || String(error)); }
|
|
371
|
+
return days;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function flush() {
|
|
375
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
376
|
+
if (!dirty) return;
|
|
377
|
+
dirty = false;
|
|
378
|
+
const kept = Object.keys(load()).sort().slice(-keepDays);
|
|
379
|
+
days = Object.fromEntries(kept.map((day) => [day, days[day]]));
|
|
380
|
+
try {
|
|
381
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
382
|
+
replaceFileAtomic(path, `${JSON.stringify({ version: 1, days }, null, 2)}\n`);
|
|
383
|
+
} catch (error) { log('editor-ai-usage:unwritable', error?.message || String(error)); }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Add to today's counters. */
|
|
387
|
+
function add(change = {}) {
|
|
388
|
+
const day = dayOf(now());
|
|
389
|
+
const row = load()[day] || (days[day] = Object.fromEntries(USAGE_FIELDS.map((f) => [f, 0])));
|
|
390
|
+
let changed = false;
|
|
391
|
+
for (const field of USAGE_FIELDS) {
|
|
392
|
+
const n = Math.round(Number(change[field]) || 0);
|
|
393
|
+
if (n > 0) { row[field] += n; changed = true; }
|
|
394
|
+
}
|
|
395
|
+
if (!changed) return;
|
|
396
|
+
dirty = true;
|
|
397
|
+
if (!timer && flushMs > 0) { timer = setTimeout(flush, flushMs); timer.unref?.(); }
|
|
398
|
+
if (flushMs <= 0) flush();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function today() {
|
|
402
|
+
const day = dayOf(now());
|
|
403
|
+
return { day, ...(load()[day] || Object.fromEntries(USAGE_FIELDS.map((f) => [f, 0]))) };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { add, today, flush, path };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Models ──
|
|
410
|
+
|
|
411
|
+
const FAST_NAME = /(flash|nano|lite|instant|fast|mini|haiku|luna|tiny|small)/i;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A model of the picker, from a row of the Assistant's catalog (lib/assistant-catalog.js parseOpenCodeProviders).
|
|
415
|
+
* `fast`: a small model by its name or its price, the kind worth trying for completions.
|
|
416
|
+
*/
|
|
417
|
+
export function modelOfRow(row) {
|
|
418
|
+
const price = row.price ? { input: row.price.input, output: row.price.output } : null;
|
|
419
|
+
return {
|
|
420
|
+
id: row.id,
|
|
421
|
+
label: row.label || row.id,
|
|
422
|
+
provider: row.upstream || row.id.split('/')[0],
|
|
423
|
+
price,
|
|
424
|
+
free: row.price?.basis === 'free',
|
|
425
|
+
fast: row.tier === 'small',
|
|
426
|
+
context: row.contextWindow ?? null,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The models "Find fastest" tries: the selected one, then the fast ones, the likeliest first. At most four. */
|
|
431
|
+
export function fastestCandidates(models, selected = null, max = FASTEST_CANDIDATES) {
|
|
432
|
+
const rank = (m) => [FAST_NAME.test(`${m.id} ${m.label}`) ? 0 : 1, m.price ? m.price.output : Number.MAX_VALUE, m.id];
|
|
433
|
+
const fast = models.filter((m) => m.fast).sort((a, b) => {
|
|
434
|
+
const ra = rank(a); const rb = rank(b);
|
|
435
|
+
return ra[0] - rb[0] || ra[1] - rb[1] || (ra[2] < rb[2] ? -1 : ra[2] > rb[2] ? 1 : 0);
|
|
436
|
+
});
|
|
437
|
+
const current = selected && models.find((m) => m.id === selected);
|
|
438
|
+
const out = current ? [current] : [];
|
|
439
|
+
for (const model of fast) if (out.length < max && !out.includes(model)) out.push(model);
|
|
440
|
+
return out.slice(0, max);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The fixed sample a test sends: never the user's code. */
|
|
444
|
+
export const TEST_REQUEST = Object.freeze({
|
|
445
|
+
path: 'samples/timing.js',
|
|
446
|
+
language: 'JavaScript',
|
|
447
|
+
indentUnit: ' ',
|
|
448
|
+
prefix: '// Calls fn once the calls have stopped for `wait` milliseconds.\nexport function debounce(fn, wait) {\n let timer = null;\n return function debounced(...args) {\n ',
|
|
449
|
+
suffix: '\n };\n}\n',
|
|
450
|
+
header: '',
|
|
451
|
+
neighbors: [],
|
|
452
|
+
trigger: 'manual',
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ── The service ──
|
|
456
|
+
|
|
457
|
+
const validModelId = (id) => typeof id === 'string' && id.length <= 300 && id.includes('/') && !id.startsWith('/') && !id.endsWith('/');
|
|
458
|
+
const reply = (status, body) => ({ status, body });
|
|
459
|
+
// An engine's own words, for the page and the log.
|
|
460
|
+
const messageOf = (error, key = '') => redactKey(String(error?.message || error || 'The request failed'), key).replace(/\s+/g, ' ').trim().slice(0, 400);
|
|
461
|
+
// A failed call to the user's endpoint: only a sentence of lib/editor-completion-endpoint.js leaves (the status
|
|
462
|
+
// and what the failure is called). Whatever else an error says is not passed on, since an endpoint may have
|
|
463
|
+
// repeated the key in any form; the key as it was sent is taken out all the same.
|
|
464
|
+
const endpointMessageOf = (error, key = '') => messageOf(asEndpointFailure(error), key);
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* @param {object} deps
|
|
468
|
+
* @param {() => object} deps.readConfig the editor's stored configuration (lib/editor-config.js store.read)
|
|
469
|
+
* @param {{ opencode: object, endpoint: object }} deps.engines `opencode`: { complete, providers, running, drain?, stop };
|
|
470
|
+
* `endpoint`: { complete }
|
|
471
|
+
* @param {() => string} [deps.getEndpointKey] SYNABUN_EDITOR_AI_KEY, or '' when none is set
|
|
472
|
+
* @param {() => Promise<object | null>} [deps.sharedProviders] the shared OpenCode serve's /provider answer when
|
|
473
|
+
* that serve is running (the model list, without starting anything), else null
|
|
474
|
+
* @param {(id: string) => boolean} [deps.isHiddenModel] models the user hid in Settings → OpenCode
|
|
475
|
+
* @param {object} deps.usage createUsageStore()
|
|
476
|
+
* @param {() => number} [deps.now]
|
|
477
|
+
* @param {number} [deps.limitPerMinute]
|
|
478
|
+
* @param {number} [deps.timeoutMs] after this long an engine call is abandoned
|
|
479
|
+
*/
|
|
480
|
+
export function createEditorCompletionService({
|
|
481
|
+
readConfig, engines, getEndpointKey = () => '', sharedProviders = async () => null, isHiddenModel = () => false,
|
|
482
|
+
usage, now = Date.now, limitPerMinute = RATE_LIMIT_PER_MINUTE, timeoutMs = ENGINE_TIMEOUT_MS, log = () => {},
|
|
483
|
+
} = {}) {
|
|
484
|
+
if (typeof readConfig !== 'function') throw new Error('createEditorCompletionService requires readConfig');
|
|
485
|
+
if (!engines?.opencode || !engines?.endpoint) throw new Error('createEditorCompletionService requires both engines');
|
|
486
|
+
if (!usage) throw new Error('createEditorCompletionService requires a usage store');
|
|
487
|
+
const limiter = createRateLimiter({ limit: limitPerMinute, now });
|
|
488
|
+
const ai = () => readConfig().ai;
|
|
489
|
+
|
|
490
|
+
/** Which engine and model a configuration means, or why there is none. */
|
|
491
|
+
function targetOf(config, { engine = config.engine, model = null } = {}) {
|
|
492
|
+
if (engine === 'endpoint') {
|
|
493
|
+
const { url, model: name, kind, shape } = config.endpoint;
|
|
494
|
+
if (!url || !name) return { error: 'no-endpoint', message: 'The endpoint has no address or no model yet' };
|
|
495
|
+
return { engine: 'endpoint', endpoint: { url, model: name, kind, shape }, label: name };
|
|
496
|
+
}
|
|
497
|
+
const id = model || config.model;
|
|
498
|
+
if (!validModelId(id)) return { error: 'no-model', message: 'No model is selected yet' };
|
|
499
|
+
return { engine: 'opencode', model: id, label: id };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** One request through an engine. Never throws: a failure, a timeout and an abort are results. */
|
|
503
|
+
async function run(target, request, { maxLines, signal } = {}) {
|
|
504
|
+
const started = now();
|
|
505
|
+
const timer = new AbortController();
|
|
506
|
+
const timeout = setTimeout(() => timer.abort(), timeoutMs);
|
|
507
|
+
const both = signal ? AbortSignal.any([signal, timer.signal]) : timer.signal;
|
|
508
|
+
try {
|
|
509
|
+
const prompt = buildChatPrompt(request);
|
|
510
|
+
const out = target.engine === 'endpoint'
|
|
511
|
+
? await engines.endpoint.complete({
|
|
512
|
+
endpoint: target.endpoint, key: getEndpointKey(), request, prompt, signal: both,
|
|
513
|
+
isDone: (raw) => replyIsComplete(raw, { chat: target.endpoint.shape === 'chat', suffix: request.suffix, maxLines }),
|
|
514
|
+
})
|
|
515
|
+
: await engines.opencode.complete({ model: target.model, user: prompt.user, signal: both });
|
|
516
|
+
const tokens = out?.tokens && (out.tokens.input || out.tokens.output)
|
|
517
|
+
? { input: Math.round(Number(out.tokens.input) || 0), output: Math.round(Number(out.tokens.output) || 0) } : null;
|
|
518
|
+
if (tokens) usage.add({ tokensIn: tokens.input, tokensOut: tokens.output });
|
|
519
|
+
return { ok: true, completion: cleanCompletion(out?.text, { prefix: request.prefix, suffix: request.suffix, maxLines }), ms: now() - started, model: target.label, tokens };
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (signal?.aborted) return { aborted: true };
|
|
522
|
+
if (timer.signal.aborted) return { ok: false, reason: 'timeout', message: `No answer within ${Math.round(timeoutMs / 1000)} seconds`, ms: now() - started, model: target.label };
|
|
523
|
+
const message = target.engine === 'endpoint' ? endpointMessageOf(error, getEndpointKey()) : messageOf(error);
|
|
524
|
+
log('editor-complete:engine', message);
|
|
525
|
+
return { ok: false, message, ms: now() - started, model: target.label };
|
|
526
|
+
} finally {
|
|
527
|
+
clearTimeout(timeout);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* One request to a model, whoever asks for it: a completion, a test, a run of "Find fastest" (the first one that
|
|
533
|
+
* only warms the connection up included). Each takes one of the minute's requests before anything is sent, and
|
|
534
|
+
* is counted when it is. Out of requests, nothing is sent: `{ ok: false, limited: true, reason: 'rate', … }`.
|
|
535
|
+
*/
|
|
536
|
+
async function attempt(target, request, options) {
|
|
537
|
+
const slot = limiter.take();
|
|
538
|
+
if (!slot.ok) {
|
|
539
|
+
const seconds = Math.max(1, Math.ceil(slot.retryAfterMs / 1000));
|
|
540
|
+
return { ok: false, limited: true, reason: 'rate', retryAfterMs: slot.retryAfterMs, message: `Too many requests in the last minute: try again in ${seconds} seconds`, ms: null, model: target.label };
|
|
541
|
+
}
|
|
542
|
+
usage.add({ requests: 1 });
|
|
543
|
+
return run(target, request, options);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* POST /api/editor/complete.
|
|
548
|
+
* @param {object} body
|
|
549
|
+
* @param {{ signal?: AbortSignal }} [o] `signal` fires when the page gave up on the request
|
|
550
|
+
* @returns {Promise<{ status: number, body: object } | { aborted: true }>}
|
|
551
|
+
*/
|
|
552
|
+
async function complete(body, { signal } = {}) {
|
|
553
|
+
const config = ai();
|
|
554
|
+
if (!config.enabled) return reply(409, { ok: false, code: 'disabled' });
|
|
555
|
+
const parsed = normalizeCompletionRequest(body);
|
|
556
|
+
if (!parsed.ok) return reply(400, { ok: false, code: 'invalid', error: parsed.error });
|
|
557
|
+
if (!config.neighbors) parsed.request.neighbors = [];
|
|
558
|
+
const refused = refusal(parsed.request, config.exclude);
|
|
559
|
+
if (refused) return reply(403, { ok: false, code: 'excluded', reason: refused.reason });
|
|
560
|
+
const target = targetOf(config);
|
|
561
|
+
if (target.error) return reply(502, { ok: false, code: 'engine', reason: target.error, message: target.message });
|
|
562
|
+
const result = await attempt(target, parsed.request, { maxLines: config.maxLines, signal });
|
|
563
|
+
if (result.limited) return reply(429, { ok: false, code: 'rate', retryAfterMs: result.retryAfterMs });
|
|
564
|
+
if (result.aborted) return { aborted: true };
|
|
565
|
+
if (!result.ok) return reply(502, { ok: false, code: 'engine', message: result.message, ...(result.reason ? { reason: result.reason } : {}) });
|
|
566
|
+
return reply(200, { ok: true, completion: result.completion, ms: result.ms, model: result.model, ...(result.tokens ? { tokens: result.tokens } : {}) });
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const testResult = (model, result) => (result.ok
|
|
570
|
+
? { model, ok: true, ms: result.ms, tokens: result.tokens, sample: result.completion.slice(0, 400) }
|
|
571
|
+
: {
|
|
572
|
+
model, ok: false, ms: result.ms ?? null, tokens: null, sample: '', error: result.message,
|
|
573
|
+
...(result.reason ? { reason: result.reason } : {}), ...(result.limited ? { retryAfterMs: result.retryAfterMs } : {}),
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
async function testOne(target, { signal, twice = false } = {}) {
|
|
577
|
+
let result = await attempt(target, TEST_REQUEST, { maxLines: 12, signal });
|
|
578
|
+
// The first request to a model also pays for the connection: when it went well, the second one is the measure
|
|
579
|
+
// (when there is a request left for it; the first one stands otherwise).
|
|
580
|
+
if (twice && result.ok && result.ms <= timeoutMs / 2 && !signal?.aborted) {
|
|
581
|
+
const again = await attempt(target, TEST_REQUEST, { maxLines: 12, signal });
|
|
582
|
+
if (again.ok && again.ms < result.ms) result = again;
|
|
583
|
+
}
|
|
584
|
+
return result;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* POST /api/editor/complete/test: the fixed sample through one model, through the endpoint, or through up to
|
|
589
|
+
* four fast models ("Find fastest"). It runs whether or not the feature is on: it is how a model is chosen.
|
|
590
|
+
*/
|
|
591
|
+
async function test(body = {}, { signal } = {}) {
|
|
592
|
+
const config = ai();
|
|
593
|
+
const input = isObject(body) ? body : {};
|
|
594
|
+
const engine = input.engine === 'endpoint' || input.engine === 'opencode' ? input.engine : config.engine;
|
|
595
|
+
if (engine === 'endpoint') {
|
|
596
|
+
const target = targetOf(config, { engine: 'endpoint' });
|
|
597
|
+
if (target.error) return reply(200, { ok: true, results: [{ model: config.endpoint.model || '', ok: false, ms: null, tokens: null, sample: '', error: target.message, reason: target.error }], fastest: null });
|
|
598
|
+
const result = await testOne(target, { signal });
|
|
599
|
+
if (result.aborted) return { aborted: true };
|
|
600
|
+
return reply(200, { ok: true, results: [testResult(target.label, result)], fastest: null });
|
|
601
|
+
}
|
|
602
|
+
if (input.fastest) {
|
|
603
|
+
const listed = await models({ start: true, signal });
|
|
604
|
+
const candidates = fastestCandidates(listed.body.models, config.model);
|
|
605
|
+
if (!candidates.length) return reply(200, { ok: true, results: [], fastest: null, reason: listed.body.available ? 'no-fast-model' : 'unavailable', ...(listed.body.message ? { message: listed.body.message } : {}) });
|
|
606
|
+
const results = [];
|
|
607
|
+
let limited = null;
|
|
608
|
+
for (const candidate of candidates) {
|
|
609
|
+
const result = await testOne({ engine: 'opencode', model: candidate.id, label: candidate.id }, { signal, twice: true });
|
|
610
|
+
if (result.aborted) return { aborted: true };
|
|
611
|
+
results.push(testResult(candidate.id, result));
|
|
612
|
+
// Out of requests: the models after this one are not asked. What was measured so far stands.
|
|
613
|
+
if (result.limited) { limited = result; break; }
|
|
614
|
+
}
|
|
615
|
+
const best = results.filter((r) => r.ok && r.sample).sort((a, b) => a.ms - b.ms)[0] || null;
|
|
616
|
+
return reply(200, {
|
|
617
|
+
ok: true, results, fastest: best ? best.model : null,
|
|
618
|
+
...(limited && !best ? { reason: 'rate', message: limited.message, retryAfterMs: limited.retryAfterMs } : {}),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
const target = targetOf(config, { engine: 'opencode', model: validModelId(input.model) ? input.model : null });
|
|
622
|
+
if (target.error) return reply(200, { ok: true, results: [{ model: '', ok: false, ms: null, tokens: null, sample: '', error: target.message, reason: target.error }], fastest: null });
|
|
623
|
+
const result = await testOne(target, { signal });
|
|
624
|
+
if (result.aborted) return { aborted: true };
|
|
625
|
+
return reply(200, { ok: true, results: [testResult(target.label, result)], fastest: null });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* GET /api/editor/complete/models: the connected OpenCode models the user has not hidden. Read from the
|
|
630
|
+
* dedicated serve when it is running, else from the shared one; the dedicated serve is started for it only
|
|
631
|
+
* when `start` is set (the user asked for the list, or for a test).
|
|
632
|
+
*/
|
|
633
|
+
async function models({ start = false, signal } = {}) {
|
|
634
|
+
let data = null;
|
|
635
|
+
let source = null;
|
|
636
|
+
let message = '';
|
|
637
|
+
if (engines.opencode.running()) {
|
|
638
|
+
data = await engines.opencode.providers({ signal }).catch(() => null);
|
|
639
|
+
if (data) source = 'dedicated';
|
|
640
|
+
}
|
|
641
|
+
if (!data) {
|
|
642
|
+
data = await Promise.resolve().then(() => sharedProviders()).catch(() => null);
|
|
643
|
+
if (data) source = 'shared';
|
|
644
|
+
}
|
|
645
|
+
if (!data && start) {
|
|
646
|
+
data = await engines.opencode.providers({ start: true, signal }).catch((error) => { message = messageOf(error); return null; });
|
|
647
|
+
if (data) source = 'dedicated';
|
|
648
|
+
}
|
|
649
|
+
const selected = ai().model;
|
|
650
|
+
if (!data) return reply(200, { ok: true, available: false, source: null, models: [], selected, ...(message ? { message } : {}) });
|
|
651
|
+
const list = parseOpenCodeProviders(data).filter((row) => !isHiddenModel(row.id)).map(modelOfRow);
|
|
652
|
+
list.sort((a, b) => Number(b.fast) - Number(a.fast) || a.label.localeCompare(b.label) || (a.id < b.id ? -1 : 1));
|
|
653
|
+
return reply(200, { ok: true, available: true, source, models: list, selected });
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const count = (v) => Math.max(0, Math.min(100, Math.round(Number(v) || 0)));
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
complete,
|
|
660
|
+
test,
|
|
661
|
+
models,
|
|
662
|
+
/** GET /api/editor/complete/usage */
|
|
663
|
+
usageToday: () => reply(200, { ok: true, today: usage.today() }),
|
|
664
|
+
/** POST /api/editor/complete/feedback: suggestions the page showed, and the ones the user took. */
|
|
665
|
+
feedback(body = {}) {
|
|
666
|
+
usage.add({ shown: count(body?.shown), accepted: count(body?.accepted) });
|
|
667
|
+
return reply(200, { ok: true });
|
|
668
|
+
},
|
|
669
|
+
/**
|
|
670
|
+
* The dedicated serve is stopped and the counters are written (server shutdown). The serve is given a moment
|
|
671
|
+
* to delete the sessions of the last requests first, so none is left in OpenCode's history.
|
|
672
|
+
*/
|
|
673
|
+
async stop({ drainMs = 500 } = {}) {
|
|
674
|
+
usage.flush();
|
|
675
|
+
try { await engines.opencode.drain?.(drainMs); } catch { /* nothing to wait for */ }
|
|
676
|
+
try { engines.opencode.stop(); } catch { /* already gone */ }
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The routes of the service. Mounted at /api/editor/complete. The owner's only: `guard` (lib/http-guards.js
|
|
683
|
+
* ownerRouteGuard) refuses everybody else on the router itself; without one, anybody who reaches them is answered.
|
|
684
|
+
* @param {{ service: object, guard?: (req: object, res: object, next: () => void) => void }} deps `service`:
|
|
685
|
+
* createEditorCompletionService()
|
|
686
|
+
*/
|
|
687
|
+
export function createEditorCompletionRouter({ service, guard = null } = {}) {
|
|
688
|
+
if (!service) throw new Error('createEditorCompletionRouter requires a service');
|
|
689
|
+
const router = Router();
|
|
690
|
+
if (typeof guard === 'function') router.use(guard);
|
|
691
|
+
|
|
692
|
+
// A request the page gave up on (its socket closed before an answer) is aborted at the engine.
|
|
693
|
+
const answer = (handler) => async (req, res) => {
|
|
694
|
+
const gone = new AbortController();
|
|
695
|
+
const onClose = () => { if (!res.writableEnded) gone.abort(); };
|
|
696
|
+
res.on('close', onClose);
|
|
697
|
+
try {
|
|
698
|
+
const out = await handler(req, gone.signal);
|
|
699
|
+
if (out?.aborted || gone.signal.aborted || res.destroyed) return;
|
|
700
|
+
res.status(out.status).json(out.body);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (!res.headersSent && !res.destroyed) res.status(500).json({ ok: false, error: messageOf(error) });
|
|
703
|
+
} finally {
|
|
704
|
+
res.off('close', onClose);
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
router.post('/', answer((req, signal) => service.complete(req.body, { signal })));
|
|
709
|
+
router.post('/test', answer((req, signal) => service.test(req.body, { signal })));
|
|
710
|
+
router.get('/models', answer((req, signal) => service.models({ start: req.query.start === '1', signal })));
|
|
711
|
+
router.get('/usage', answer(() => service.usageToday()));
|
|
712
|
+
router.post('/feedback', answer((req) => service.feedback(req.body)));
|
|
713
|
+
return router;
|
|
714
|
+
}
|