mycode-cli 0.1.0__py3-none-any.whl
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.
- mycode/__init__.py +1 -0
- mycode/cli/__init__.py +1 -0
- mycode/cli/chat.py +650 -0
- mycode/cli/main.py +245 -0
- mycode/cli/render.py +693 -0
- mycode/cli/runtime.py +271 -0
- mycode/cli/theme.py +109 -0
- mycode/core/__init__.py +30 -0
- mycode/core/agent.py +515 -0
- mycode/core/config.py +551 -0
- mycode/core/messages.py +166 -0
- mycode/core/models.py +144 -0
- mycode/core/models_catalog.json +2090 -0
- mycode/core/providers/__init__.py +86 -0
- mycode/core/providers/anthropic_like.py +366 -0
- mycode/core/providers/base.py +351 -0
- mycode/core/providers/gemini.py +321 -0
- mycode/core/providers/openai_chat.py +356 -0
- mycode/core/providers/openai_responses.py +326 -0
- mycode/core/session.py +537 -0
- mycode/core/system_prompt.md +10 -0
- mycode/core/system_prompt.py +319 -0
- mycode/core/tools.py +898 -0
- mycode/server/__init__.py +1 -0
- mycode/server/app.py +52 -0
- mycode/server/deps.py +29 -0
- mycode/server/routers/__init__.py +7 -0
- mycode/server/routers/chat.py +320 -0
- mycode/server/routers/sessions.py +77 -0
- mycode/server/routers/workspaces.py +92 -0
- mycode/server/run_manager.py +195 -0
- mycode/server/schemas.py +66 -0
- mycode/server/static/assets/EditDiff-C1ql7kft.js +12 -0
- mycode/server/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- mycode/server/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- mycode/server/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- mycode/server/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- mycode/server/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- mycode/server/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- mycode/server/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- mycode/server/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- mycode/server/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- mycode/server/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- mycode/server/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- mycode/server/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- mycode/server/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- mycode/server/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- mycode/server/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- mycode/server/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- mycode/server/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- mycode/server/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- mycode/server/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- mycode/server/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- mycode/server/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- mycode/server/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- mycode/server/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- mycode/server/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- mycode/server/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- mycode/server/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- mycode/server/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- mycode/server/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- mycode/server/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- mycode/server/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- mycode/server/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- mycode/server/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- mycode/server/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- mycode/server/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- mycode/server/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- mycode/server/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- mycode/server/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- mycode/server/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- mycode/server/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- mycode/server/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- mycode/server/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- mycode/server/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- mycode/server/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- mycode/server/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- mycode/server/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- mycode/server/static/assets/abap-BdImnpbu.js +1 -0
- mycode/server/static/assets/actionscript-3-CoDkCxhg.js +1 -0
- mycode/server/static/assets/ada-bCR0ucgS.js +1 -0
- mycode/server/static/assets/andromeeda-C4gqWexZ.js +1 -0
- mycode/server/static/assets/angular-html-DA-rfuFy.js +1 -0
- mycode/server/static/assets/angular-ts-BrjP3tb8.js +1 -0
- mycode/server/static/assets/apache-Pmp26Uib.js +1 -0
- mycode/server/static/assets/apex-D8_7TLub.js +1 -0
- mycode/server/static/assets/apl-CORt7UWP.js +1 -0
- mycode/server/static/assets/applescript-Co6uUVPk.js +1 -0
- mycode/server/static/assets/ara-BRHolxvo.js +1 -0
- mycode/server/static/assets/asciidoc-Ve4PFQV2.js +1 -0
- mycode/server/static/assets/asm-D_Q5rh1f.js +1 -0
- mycode/server/static/assets/astro-HNnZUWAn.js +1 -0
- mycode/server/static/assets/aurora-x-D-2ljcwZ.js +1 -0
- mycode/server/static/assets/auto-render-xntwXHOX.js +261 -0
- mycode/server/static/assets/awk-DMzUqQB5.js +1 -0
- mycode/server/static/assets/ayu-dark-DYE7WIF3.js +1 -0
- mycode/server/static/assets/ayu-light-BA47KaF1.js +1 -0
- mycode/server/static/assets/ayu-mirage-32ctXXKs.js +1 -0
- mycode/server/static/assets/ballerina-BFfxhgS-.js +1 -0
- mycode/server/static/assets/bat-BkioyH1T.js +1 -0
- mycode/server/static/assets/beancount-k_qm7-4y.js +1 -0
- mycode/server/static/assets/berry-uYugtg8r.js +1 -0
- mycode/server/static/assets/bibtex-CHM0blh-.js +1 -0
- mycode/server/static/assets/bicep-Bmn6On1c.js +1 -0
- mycode/server/static/assets/bird2-BIv1doCn.js +1 -0
- mycode/server/static/assets/blade-BjGOyj-B.js +1 -0
- mycode/server/static/assets/bsl-BO_Y6i37.js +1 -0
- mycode/server/static/assets/c-BIGW1oBm.js +1 -0
- mycode/server/static/assets/c3-eo99z4R2.js +1 -0
- mycode/server/static/assets/cadence-Bv_4Rxtq.js +1 -0
- mycode/server/static/assets/cairo-KRGpt6FW.js +1 -0
- mycode/server/static/assets/catppuccin-frappe-DFWUc33u.js +1 -0
- mycode/server/static/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
- mycode/server/static/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
- mycode/server/static/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
- mycode/server/static/assets/clarity-D53aC0YG.js +1 -0
- mycode/server/static/assets/clojure-P80f7IUj.js +1 -0
- mycode/server/static/assets/cmake-D1j8_8rp.js +1 -0
- mycode/server/static/assets/cobol-nBiQ_Alo.js +1 -0
- mycode/server/static/assets/codeowners-Bp6g37R7.js +1 -0
- mycode/server/static/assets/codeql-DsOJ9woJ.js +1 -0
- mycode/server/static/assets/coffee-Ch7k5sss.js +1 -0
- mycode/server/static/assets/common-lisp-Cg-RD9OK.js +1 -0
- mycode/server/static/assets/coq-DkFqJrB1.js +1 -0
- mycode/server/static/assets/cpp-CofmeUqb.js +1 -0
- mycode/server/static/assets/crystal-DNxU26gB.js +1 -0
- mycode/server/static/assets/csharp-COcwbKMJ.js +1 -0
- mycode/server/static/assets/css-CLj8gQPS.js +1 -0
- mycode/server/static/assets/csv-fuZLfV_i.js +1 -0
- mycode/server/static/assets/cue-D82EKSYY.js +1 -0
- mycode/server/static/assets/cypher-COkxafJQ.js +1 -0
- mycode/server/static/assets/d-85-TOEBH.js +1 -0
- mycode/server/static/assets/dark-plus-C3mMm8J8.js +1 -0
- mycode/server/static/assets/dart-bE4Kk8sk.js +1 -0
- mycode/server/static/assets/dax-CEL-wOlO.js +1 -0
- mycode/server/static/assets/desktop-BmXAJ9_W.js +1 -0
- mycode/server/static/assets/diff-D97Zzqfu.js +1 -0
- mycode/server/static/assets/docker-BcOcwvcX.js +1 -0
- mycode/server/static/assets/dotenv-Da5cRb03.js +1 -0
- mycode/server/static/assets/dracula-BzJJZx-M.js +1 -0
- mycode/server/static/assets/dracula-soft-BXkSAIEj.js +1 -0
- mycode/server/static/assets/dream-maker-BtqSS_iP.js +1 -0
- mycode/server/static/assets/edge-FbVlp4U3.js +1 -0
- mycode/server/static/assets/elixir-CkH2-t6x.js +1 -0
- mycode/server/static/assets/elm-DbKCFpqz.js +1 -0
- mycode/server/static/assets/emacs-lisp-CXvaQtF9.js +1 -0
- mycode/server/static/assets/erb-BYCe7drp.js +1 -0
- mycode/server/static/assets/erlang-DsQrWhSR.js +1 -0
- mycode/server/static/assets/everforest-dark-BgDCqdQA.js +1 -0
- mycode/server/static/assets/everforest-light-C8M2exoo.js +1 -0
- mycode/server/static/assets/fennel-BYunw83y.js +1 -0
- mycode/server/static/assets/fish-BvzEVeQv.js +1 -0
- mycode/server/static/assets/fluent-C4IJs8-o.js +1 -0
- mycode/server/static/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
- mycode/server/static/assets/fortran-free-form-BxgE0vQu.js +1 -0
- mycode/server/static/assets/fsharp-CXgrBDvD.js +1 -0
- mycode/server/static/assets/gdresource-BOOCDP_w.js +1 -0
- mycode/server/static/assets/gdscript-C5YyOfLZ.js +1 -0
- mycode/server/static/assets/gdshader-DkwncUOv.js +1 -0
- mycode/server/static/assets/genie-D0YGMca9.js +1 -0
- mycode/server/static/assets/gherkin-DyxjwDmM.js +1 -0
- mycode/server/static/assets/git-commit-F4YmCXRG.js +1 -0
- mycode/server/static/assets/git-rebase-r7XF79zn.js +1 -0
- mycode/server/static/assets/github-dark-DHJKELXO.js +1 -0
- mycode/server/static/assets/github-dark-default-Cuk6v7N8.js +1 -0
- mycode/server/static/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
- mycode/server/static/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
- mycode/server/static/assets/github-light-DAi9KRSo.js +1 -0
- mycode/server/static/assets/github-light-default-D7oLnXFd.js +1 -0
- mycode/server/static/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
- mycode/server/static/assets/gleam-BspZqrRM.js +1 -0
- mycode/server/static/assets/glimmer-js-ByusRIyA.js +1 -0
- mycode/server/static/assets/glimmer-ts-BfAWNZQY.js +1 -0
- mycode/server/static/assets/glsl-DplSGwfg.js +1 -0
- mycode/server/static/assets/gn-n2N0HUVH.js +1 -0
- mycode/server/static/assets/gnuplot-DdkO51Og.js +1 -0
- mycode/server/static/assets/go-C27-OAKa.js +1 -0
- mycode/server/static/assets/graphql-ChdNCCLP.js +1 -0
- mycode/server/static/assets/groovy-gcz8RCvz.js +1 -0
- mycode/server/static/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
- mycode/server/static/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
- mycode/server/static/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
- mycode/server/static/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
- mycode/server/static/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
- mycode/server/static/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
- mycode/server/static/assets/hack-i7_Ulhet.js +1 -0
- mycode/server/static/assets/haml-D5jkg6IW.js +1 -0
- mycode/server/static/assets/handlebars-BpdQsYii.js +1 -0
- mycode/server/static/assets/haskell-Df6bDoY_.js +1 -0
- mycode/server/static/assets/haxe-CzTSHFRz.js +1 -0
- mycode/server/static/assets/hcl-BWvSN4gD.js +1 -0
- mycode/server/static/assets/hjson-D5-asLiD.js +1 -0
- mycode/server/static/assets/hlsl-D3lLCCz7.js +1 -0
- mycode/server/static/assets/horizon-BUw7H-hv.js +1 -0
- mycode/server/static/assets/horizon-bright-CUuTKBJd.js +1 -0
- mycode/server/static/assets/houston-DnULxvSX.js +1 -0
- mycode/server/static/assets/html-derivative-DlHx6ybY.js +1 -0
- mycode/server/static/assets/html-pp8916En.js +1 -0
- mycode/server/static/assets/http-jrhK8wxY.js +1 -0
- mycode/server/static/assets/hurl-irOxFIW8.js +1 -0
- mycode/server/static/assets/hxml-Bvhsp5Yf.js +1 -0
- mycode/server/static/assets/hy-DFXneXwc.js +1 -0
- mycode/server/static/assets/imba-DGztddWO.js +1 -0
- mycode/server/static/assets/index-B4e4WQPq.css +1 -0
- mycode/server/static/assets/index-C2xTNJGd.js +203 -0
- mycode/server/static/assets/ini-BEwlwnbL.js +1 -0
- mycode/server/static/assets/java-CylS5w8V.js +1 -0
- mycode/server/static/assets/javascript-wDzz0qaB.js +1 -0
- mycode/server/static/assets/jinja-f2NsQr07.js +1 -0
- mycode/server/static/assets/jison-wvAkD_A8.js +1 -0
- mycode/server/static/assets/json-Cp-IABpG.js +1 -0
- mycode/server/static/assets/json5-C9tS-k6U.js +1 -0
- mycode/server/static/assets/jsonc-Des-eS-w.js +1 -0
- mycode/server/static/assets/jsonl-DcaNXYhu.js +1 -0
- mycode/server/static/assets/jsonnet-DFQXde-d.js +1 -0
- mycode/server/static/assets/jssm-C2t-YnRu.js +1 -0
- mycode/server/static/assets/jsx-g9-lgVsj.js +1 -0
- mycode/server/static/assets/julia-CxzCAyBv.js +1 -0
- mycode/server/static/assets/just-VxiPbLrw.js +1 -0
- mycode/server/static/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
- mycode/server/static/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
- mycode/server/static/assets/kanagawa-wave-DWedfzmr.js +1 -0
- mycode/server/static/assets/katex-DnJR2-55.css +1 -0
- mycode/server/static/assets/kdl-DV7GczEv.js +1 -0
- mycode/server/static/assets/kotlin-BdnUsdx6.js +1 -0
- mycode/server/static/assets/kusto-wEQ09or8.js +1 -0
- mycode/server/static/assets/laserwave-DUszq2jm.js +1 -0
- mycode/server/static/assets/latex-CWtU0Tv5.js +1 -0
- mycode/server/static/assets/lean-BZvkOJ9d.js +1 -0
- mycode/server/static/assets/less-B1dDrJ26.js +1 -0
- mycode/server/static/assets/light-plus-B7mTdjB0.js +1 -0
- mycode/server/static/assets/liquid-C0sCDyMI.js +1 -0
- mycode/server/static/assets/llvm-DjAJT7YJ.js +1 -0
- mycode/server/static/assets/log-2UxHyX5q.js +1 -0
- mycode/server/static/assets/logo-BtOb2qkB.js +1 -0
- mycode/server/static/assets/lua-BaeVxFsk.js +1 -0
- mycode/server/static/assets/luau-C-HG3fhB.js +1 -0
- mycode/server/static/assets/make-CHLpvVh8.js +1 -0
- mycode/server/static/assets/markdown-Cvjx9yec.js +1 -0
- mycode/server/static/assets/marko-DjSrsDqO.js +1 -0
- mycode/server/static/assets/material-theme-D5KoaKCx.js +1 -0
- mycode/server/static/assets/material-theme-darker-BfHTSMKl.js +1 -0
- mycode/server/static/assets/material-theme-lighter-B0m2ddpp.js +1 -0
- mycode/server/static/assets/material-theme-ocean-CyktbL80.js +1 -0
- mycode/server/static/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
- mycode/server/static/assets/matlab-D7o27uSR.js +1 -0
- mycode/server/static/assets/mdc-DTYItulj.js +1 -0
- mycode/server/static/assets/mdx-Cmh6b_Ma.js +1 -0
- mycode/server/static/assets/mermaid-mWjccvbQ.js +1 -0
- mycode/server/static/assets/min-dark-CafNBF8u.js +1 -0
- mycode/server/static/assets/min-light-CTRr51gU.js +1 -0
- mycode/server/static/assets/mipsasm-CKIfxQSi.js +1 -0
- mycode/server/static/assets/mojo-rZm6bMo-.js +1 -0
- mycode/server/static/assets/monokai-D4h5O-jR.js +1 -0
- mycode/server/static/assets/moonbit-_H4v1dQx.js +1 -0
- mycode/server/static/assets/move-IF9eRakj.js +1 -0
- mycode/server/static/assets/narrat-DRg8JJMk.js +1 -0
- mycode/server/static/assets/nextflow-C-mBbutL.js +1 -0
- mycode/server/static/assets/nextflow-groovy-vE_lwT2v.js +1 -0
- mycode/server/static/assets/nginx-BpAMiNFr.js +1 -0
- mycode/server/static/assets/night-owl-C39BiMTA.js +1 -0
- mycode/server/static/assets/night-owl-light-CMTm3GFP.js +1 -0
- mycode/server/static/assets/nim-BIad80T-.js +1 -0
- mycode/server/static/assets/nix-CwoSXNpI.js +1 -0
- mycode/server/static/assets/nord-Ddv68eIx.js +1 -0
- mycode/server/static/assets/nushell-Cz2AlsmD.js +1 -0
- mycode/server/static/assets/objective-c-DXmwc3jG.js +1 -0
- mycode/server/static/assets/objective-cpp-CLxacb5B.js +1 -0
- mycode/server/static/assets/ocaml-C0hk2d4L.js +1 -0
- mycode/server/static/assets/odin-BBf5iR-q.js +1 -0
- mycode/server/static/assets/one-dark-pro-DVMEJ2y_.js +1 -0
- mycode/server/static/assets/one-light-C3Wv6jpd.js +1 -0
- mycode/server/static/assets/openscad-C4EeE6gA.js +1 -0
- mycode/server/static/assets/pascal-D93ZcfNL.js +1 -0
- mycode/server/static/assets/perl-NvoQZIq0.js +1 -0
- mycode/server/static/assets/php-R6g_5hLQ.js +1 -0
- mycode/server/static/assets/pkl-u5AG7uiY.js +1 -0
- mycode/server/static/assets/plastic-3e1v2bzS.js +1 -0
- mycode/server/static/assets/plsql-ChMvpjG-.js +1 -0
- mycode/server/static/assets/po-BTJTHyun.js +1 -0
- mycode/server/static/assets/poimandres-CS3Unz2-.js +1 -0
- mycode/server/static/assets/polar-C0HS_06l.js +1 -0
- mycode/server/static/assets/postcss-CXtECtnM.js +1 -0
- mycode/server/static/assets/powerquery-CEu0bR-o.js +1 -0
- mycode/server/static/assets/powershell-Dpen1YoG.js +1 -0
- mycode/server/static/assets/prisma-Dd19v3D-.js +1 -0
- mycode/server/static/assets/prolog-CbFg5uaA.js +1 -0
- mycode/server/static/assets/proto-C7zT0LnQ.js +1 -0
- mycode/server/static/assets/pug-DKIMFp6K.js +1 -0
- mycode/server/static/assets/puppet-BMWR74SV.js +1 -0
- mycode/server/static/assets/purescript-CklMAg4u.js +1 -0
- mycode/server/static/assets/python-B6aJPvgy.js +1 -0
- mycode/server/static/assets/qml-3beO22l8.js +1 -0
- mycode/server/static/assets/qmldir-C8lEn-DE.js +1 -0
- mycode/server/static/assets/qss-IeuSbFQv.js +1 -0
- mycode/server/static/assets/r-Dspwwk_N.js +1 -0
- mycode/server/static/assets/racket-BqYA7rlc.js +1 -0
- mycode/server/static/assets/raku-DXvB9xmW.js +1 -0
- mycode/server/static/assets/razor-BDqjjVU7.js +1 -0
- mycode/server/static/assets/red-bN70gL4F.js +1 -0
- mycode/server/static/assets/reg-C-SQnVFl.js +1 -0
- mycode/server/static/assets/regexp-CDVJQ6XC.js +1 -0
- mycode/server/static/assets/rel-C3B-1QV4.js +1 -0
- mycode/server/static/assets/riscv-BM1_JUlF.js +1 -0
- mycode/server/static/assets/ron-D8l8udqQ.js +1 -0
- mycode/server/static/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
- mycode/server/static/assets/rose-pine-moon-D4_iv3hh.js +1 -0
- mycode/server/static/assets/rose-pine-qdsjHGoJ.js +1 -0
- mycode/server/static/assets/rosmsg-BJDFO7_C.js +1 -0
- mycode/server/static/assets/rst-CRjBmOyv.js +1 -0
- mycode/server/static/assets/ruby-Wjq7vjNf.js +1 -0
- mycode/server/static/assets/rust-B1yitclQ.js +1 -0
- mycode/server/static/assets/sas-cz2c8ADy.js +1 -0
- mycode/server/static/assets/sass-Cj5Yp3dK.js +1 -0
- mycode/server/static/assets/scala-C151Ov-r.js +1 -0
- mycode/server/static/assets/scheme-C98Dy4si.js +1 -0
- mycode/server/static/assets/scss-D5BDwBP9.js +1 -0
- mycode/server/static/assets/sdbl-DVxCFoDh.js +1 -0
- mycode/server/static/assets/shaderlab-Dg9Lc6iA.js +1 -0
- mycode/server/static/assets/shellscript-Yzrsuije.js +1 -0
- mycode/server/static/assets/shellsession-BADoaaVG.js +1 -0
- mycode/server/static/assets/slack-dark-BthQWCQV.js +1 -0
- mycode/server/static/assets/slack-ochin-DqwNpetd.js +1 -0
- mycode/server/static/assets/smalltalk-BERRCDM3.js +1 -0
- mycode/server/static/assets/snazzy-light-Bw305WKR.js +1 -0
- mycode/server/static/assets/solarized-dark-DXbdFlpD.js +1 -0
- mycode/server/static/assets/solarized-light-L9t79GZl.js +1 -0
- mycode/server/static/assets/solidity-rGO070M0.js +1 -0
- mycode/server/static/assets/soy-8wufbnw4.js +1 -0
- mycode/server/static/assets/sparql-rVzFXLq3.js +1 -0
- mycode/server/static/assets/splunk-BtCnVYZw.js +1 -0
- mycode/server/static/assets/sql-BLtJtn59.js +1 -0
- mycode/server/static/assets/ssh-config-_ykCGR6B.js +1 -0
- mycode/server/static/assets/stata-BH5u7GGu.js +1 -0
- mycode/server/static/assets/stylus-BEDo0Tqx.js +1 -0
- mycode/server/static/assets/surrealql-Bq5Q-fJD.js +1 -0
- mycode/server/static/assets/svelte-Cy7k_4gC.js +1 -0
- mycode/server/static/assets/swift-D82vCrfD.js +1 -0
- mycode/server/static/assets/synthwave-84-CbfX1IO0.js +1 -0
- mycode/server/static/assets/system-verilog-CnnmHF94.js +1 -0
- mycode/server/static/assets/systemd-4A_iFExJ.js +1 -0
- mycode/server/static/assets/talonscript-CkByrt1z.js +1 -0
- mycode/server/static/assets/tasl-QIJgUcNo.js +1 -0
- mycode/server/static/assets/tcl-dwOrl1Do.js +1 -0
- mycode/server/static/assets/templ-DhtptRzy.js +1 -0
- mycode/server/static/assets/terraform-BETggiCN.js +1 -0
- mycode/server/static/assets/tex-idrVyKtj.js +1 -0
- mycode/server/static/assets/tokyo-night-hegEt444.js +1 -0
- mycode/server/static/assets/toml-vGWfd6FD.js +1 -0
- mycode/server/static/assets/ts-tags-DQrlYJgV.js +1 -0
- mycode/server/static/assets/tsv-B_m7g4N7.js +1 -0
- mycode/server/static/assets/tsx-COt5Ahok.js +1 -0
- mycode/server/static/assets/turtle-BsS91CYL.js +1 -0
- mycode/server/static/assets/twig-xg9kU7Mw.js +1 -0
- mycode/server/static/assets/typescript-BPQ3VLAy.js +1 -0
- mycode/server/static/assets/typespec-CAFt9gP4.js +1 -0
- mycode/server/static/assets/typst-DHCkPAjA.js +1 -0
- mycode/server/static/assets/v-BcVCzyr7.js +1 -0
- mycode/server/static/assets/vala-CsfeWuGM.js +1 -0
- mycode/server/static/assets/vb-D17OF-Vu.js +1 -0
- mycode/server/static/assets/verilog-BQ8w6xss.js +1 -0
- mycode/server/static/assets/vesper-DU1UobuO.js +1 -0
- mycode/server/static/assets/vhdl-CeAyd5Ju.js +1 -0
- mycode/server/static/assets/viml-CJc9bBzg.js +1 -0
- mycode/server/static/assets/vitesse-black-Bkuqu6BP.js +1 -0
- mycode/server/static/assets/vitesse-dark-D0r3Knsf.js +1 -0
- mycode/server/static/assets/vitesse-light-CVO1_9PV.js +1 -0
- mycode/server/static/assets/vue-D2xRrEX4.js +1 -0
- mycode/server/static/assets/vue-html-AaS7Mt5G.js +1 -0
- mycode/server/static/assets/vue-vine-BoDAl6tE.js +1 -0
- mycode/server/static/assets/vyper-CDx5xZoG.js +1 -0
- mycode/server/static/assets/wasm-CG6Dc4jp.js +1 -0
- mycode/server/static/assets/wasm-MzD3tlZU.js +1 -0
- mycode/server/static/assets/wenyan-BV7otONQ.js +1 -0
- mycode/server/static/assets/wgsl-Dx-B1_4e.js +1 -0
- mycode/server/static/assets/wikitext-BhOHFoWU.js +1 -0
- mycode/server/static/assets/wit-5i3qLPDT.js +1 -0
- mycode/server/static/assets/wolfram-lXgVvXCa.js +1 -0
- mycode/server/static/assets/xml-sdJ4AIDG.js +1 -0
- mycode/server/static/assets/xsl-CtQFsRM5.js +1 -0
- mycode/server/static/assets/yaml-Buea-lGh.js +1 -0
- mycode/server/static/assets/zenscript-DVFEvuxE.js +1 -0
- mycode/server/static/assets/zig-VOosw3JB.js +1 -0
- mycode/server/static/favicon_slashes.svg +12 -0
- mycode/server/static/index.html +35 -0
- mycode_cli-0.1.0.dist-info/METADATA +186 -0
- mycode_cli-0.1.0.dist-info/RECORD +404 -0
- mycode_cli-0.1.0.dist-info/WHEEL +4 -0
- mycode_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mycode_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
mycode/core/tools.py
ADDED
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
"""Core tool definitions and execution.
|
|
2
|
+
|
|
3
|
+
The runtime intentionally exposes only four built-in tools: ``read``,
|
|
4
|
+
``write``, ``edit``, and ``bash``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import queue
|
|
12
|
+
import shlex
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from base64 import b64encode
|
|
18
|
+
from collections import deque
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from difflib import SequenceMatcher
|
|
22
|
+
from mimetypes import guess_type
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, TextIO, cast
|
|
25
|
+
|
|
26
|
+
from mycode.core.messages import image_block, text_block
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Limits (keep token usage low)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
DEFAULT_MAX_LINES = 2000
|
|
33
|
+
DEFAULT_MAX_BYTES = 50 * 1024
|
|
34
|
+
READ_MAX_LINE_CHARS = 2000
|
|
35
|
+
|
|
36
|
+
BASH_TIMEOUT_SECONDS = 120
|
|
37
|
+
_BASH_MAX_IN_MEMORY_BYTES = 5_000_000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ToolSpec:
|
|
42
|
+
"""Built-in tool metadata and executor binding."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
input_schema: dict[str, Any]
|
|
47
|
+
method_name: str
|
|
48
|
+
streams_output: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ToolExecutionResult:
|
|
53
|
+
"""Structured tool result used by the runtime.
|
|
54
|
+
|
|
55
|
+
`model_text` is appended to session history for future provider replay.
|
|
56
|
+
`display_text` is shown to the user.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
model_text: str
|
|
60
|
+
display_text: str
|
|
61
|
+
is_error: bool = False
|
|
62
|
+
content: list[dict[str, Any]] | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
DEFAULT_TOOL_SPECS: tuple[ToolSpec, ...] = (
|
|
66
|
+
ToolSpec(
|
|
67
|
+
name="read",
|
|
68
|
+
description=(
|
|
69
|
+
"Read a UTF-8 text file or supported image file. Returns up to 2000 lines for text files. "
|
|
70
|
+
"Use offset/limit for large files. Very long lines are shortened."
|
|
71
|
+
),
|
|
72
|
+
input_schema={
|
|
73
|
+
"type": "object",
|
|
74
|
+
"properties": {
|
|
75
|
+
"path": {"type": "string", "description": "File path (relative or absolute)."},
|
|
76
|
+
"offset": {"type": "integer", "description": "Line number to start from (1-indexed)."},
|
|
77
|
+
"limit": {"type": "integer", "description": "Maximum number of lines to return."},
|
|
78
|
+
},
|
|
79
|
+
"required": ["path"],
|
|
80
|
+
"additionalProperties": False,
|
|
81
|
+
},
|
|
82
|
+
method_name="read",
|
|
83
|
+
),
|
|
84
|
+
ToolSpec(
|
|
85
|
+
name="write",
|
|
86
|
+
description="Write a file (create or overwrite).",
|
|
87
|
+
input_schema={
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"path": {"type": "string", "description": "File path (relative or absolute)."},
|
|
91
|
+
"content": {"type": "string", "description": "File content."},
|
|
92
|
+
},
|
|
93
|
+
"required": ["path", "content"],
|
|
94
|
+
"additionalProperties": False,
|
|
95
|
+
},
|
|
96
|
+
method_name="write",
|
|
97
|
+
),
|
|
98
|
+
ToolSpec(
|
|
99
|
+
name="edit",
|
|
100
|
+
description="Edit a file by replacing an exact oldText snippet with newText. oldText must match exactly.",
|
|
101
|
+
input_schema={
|
|
102
|
+
"type": "object",
|
|
103
|
+
"properties": {
|
|
104
|
+
"path": {"type": "string", "description": "File path (relative or absolute)."},
|
|
105
|
+
"oldText": {"type": "string", "description": "Exact text to replace (must match exactly)."},
|
|
106
|
+
"newText": {"type": "string", "description": "Replacement text."},
|
|
107
|
+
},
|
|
108
|
+
"required": ["path", "oldText", "newText"],
|
|
109
|
+
"additionalProperties": False,
|
|
110
|
+
},
|
|
111
|
+
method_name="edit",
|
|
112
|
+
),
|
|
113
|
+
ToolSpec(
|
|
114
|
+
name="bash",
|
|
115
|
+
description=(
|
|
116
|
+
"Run a shell command in the session working directory. "
|
|
117
|
+
"Large output returns the tail and saves the full log to a file."
|
|
118
|
+
),
|
|
119
|
+
input_schema={
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"command": {"type": "string", "description": "Shell command."},
|
|
123
|
+
"timeout": {"type": "integer", "description": "Timeout in seconds (optional)."},
|
|
124
|
+
},
|
|
125
|
+
"required": ["command"],
|
|
126
|
+
"additionalProperties": False,
|
|
127
|
+
},
|
|
128
|
+
method_name="bash",
|
|
129
|
+
streams_output=True,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Utilities
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class Truncation:
|
|
141
|
+
truncated: bool
|
|
142
|
+
truncated_by: str | None
|
|
143
|
+
output_lines: int
|
|
144
|
+
output_bytes: int
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def truncate_text(
|
|
148
|
+
text: str,
|
|
149
|
+
*,
|
|
150
|
+
max_lines: int = DEFAULT_MAX_LINES,
|
|
151
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
152
|
+
tail: bool = False,
|
|
153
|
+
) -> tuple[str, Truncation]:
|
|
154
|
+
"""Truncate text by both line and byte limits.
|
|
155
|
+
|
|
156
|
+
Returns (content, truncation).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
lines = text.splitlines()
|
|
160
|
+
out_lines: list[str] = []
|
|
161
|
+
out_bytes = 0
|
|
162
|
+
|
|
163
|
+
source = reversed(lines) if tail else lines
|
|
164
|
+
|
|
165
|
+
for line in source:
|
|
166
|
+
if len(out_lines) >= max_lines:
|
|
167
|
+
break
|
|
168
|
+
# +1 for newline when joined later
|
|
169
|
+
b = len((line + "\n").encode("utf-8"))
|
|
170
|
+
if out_bytes + b > max_bytes:
|
|
171
|
+
break
|
|
172
|
+
out_lines.append(line)
|
|
173
|
+
out_bytes += b
|
|
174
|
+
|
|
175
|
+
if tail:
|
|
176
|
+
out_lines.reverse()
|
|
177
|
+
|
|
178
|
+
content = "\n".join(out_lines)
|
|
179
|
+
truncated = len(out_lines) < len(lines) or out_bytes < len(text.encode("utf-8"))
|
|
180
|
+
|
|
181
|
+
truncated_by: str | None = None
|
|
182
|
+
if truncated:
|
|
183
|
+
if len(out_lines) < len(lines):
|
|
184
|
+
truncated_by = "lines" if len(out_lines) == max_lines else "bytes"
|
|
185
|
+
else:
|
|
186
|
+
truncated_by = "bytes"
|
|
187
|
+
|
|
188
|
+
trunc = Truncation(
|
|
189
|
+
truncated=truncated,
|
|
190
|
+
truncated_by=truncated_by,
|
|
191
|
+
output_lines=len(out_lines),
|
|
192
|
+
output_bytes=len(content.encode("utf-8")),
|
|
193
|
+
)
|
|
194
|
+
return content, trunc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def parse_tool_arguments(raw: str | None) -> dict[str, Any] | str:
|
|
198
|
+
"""Parse a JSON tool-arguments payload.
|
|
199
|
+
|
|
200
|
+
Returns either the parsed object or an error string. Keeping this helper here
|
|
201
|
+
makes tool-argument validation consistent across adapters and tests.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
if raw is None:
|
|
205
|
+
return {}
|
|
206
|
+
|
|
207
|
+
payload = raw.strip()
|
|
208
|
+
if not payload:
|
|
209
|
+
return {}
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
parsed = json.loads(payload)
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
return "error: invalid JSON arguments"
|
|
215
|
+
|
|
216
|
+
if not isinstance(parsed, dict):
|
|
217
|
+
return "error: tool arguments must decode to a JSON object"
|
|
218
|
+
|
|
219
|
+
return parsed
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def resolve_path(path: str, *, cwd: str) -> str:
|
|
223
|
+
"""Resolve path relative to cwd (without changing global process cwd)."""
|
|
224
|
+
|
|
225
|
+
p = Path(path).expanduser()
|
|
226
|
+
if not p.is_absolute():
|
|
227
|
+
p = Path(cwd) / p
|
|
228
|
+
return str(p.resolve(strict=False))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _atomic_write_text(path: Path, content: str) -> None:
|
|
232
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
233
|
+
tmp.write_text(content, encoding="utf-8")
|
|
234
|
+
tmp.replace(path)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def detect_image_mime_type(path: Path) -> str | None:
|
|
238
|
+
try:
|
|
239
|
+
with path.open("rb") as file:
|
|
240
|
+
header = file.read(16)
|
|
241
|
+
except OSError:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
245
|
+
return "image/png"
|
|
246
|
+
if header.startswith(b"\xff\xd8\xff"):
|
|
247
|
+
return "image/jpeg"
|
|
248
|
+
if header.startswith((b"GIF87a", b"GIF89a")):
|
|
249
|
+
return "image/gif"
|
|
250
|
+
if header.startswith(b"RIFF") and header[8:12] == b"WEBP":
|
|
251
|
+
return "image/webp"
|
|
252
|
+
guessed, _ = guess_type(path.name)
|
|
253
|
+
if guessed in {"image/png", "image/jpeg", "image/gif", "image/webp"}:
|
|
254
|
+
return guessed
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# Execution
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# Track active subprocesses for cancellation.
|
|
264
|
+
_ACTIVE_PROCS: set[subprocess.Popen] = set()
|
|
265
|
+
_ACTIVE_PROCS_LOCK = threading.Lock()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _kill_proc_tree(proc: subprocess.Popen[Any]) -> None:
|
|
269
|
+
try:
|
|
270
|
+
if os.name == "posix":
|
|
271
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
272
|
+
else:
|
|
273
|
+
proc.kill()
|
|
274
|
+
except Exception:
|
|
275
|
+
try:
|
|
276
|
+
proc.kill()
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cancel_all_tools() -> None:
|
|
282
|
+
"""Terminate all running bash subprocesses."""
|
|
283
|
+
|
|
284
|
+
with _ACTIVE_PROCS_LOCK:
|
|
285
|
+
procs = list(_ACTIVE_PROCS)
|
|
286
|
+
_ACTIVE_PROCS.clear()
|
|
287
|
+
|
|
288
|
+
for proc in procs:
|
|
289
|
+
_kill_proc_tree(proc)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
ToolOutputCallback = Callable[[str], None]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class ToolExecutor:
|
|
296
|
+
"""Execute tool calls for a single session."""
|
|
297
|
+
|
|
298
|
+
def __init__(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
cwd: str,
|
|
302
|
+
session_dir: Path,
|
|
303
|
+
tools: Sequence[ToolSpec] | None = None,
|
|
304
|
+
supports_image_input: bool = False,
|
|
305
|
+
):
|
|
306
|
+
self.cwd = str(Path(cwd).resolve(strict=False))
|
|
307
|
+
self.session_dir = session_dir
|
|
308
|
+
self.supports_image_input = supports_image_input
|
|
309
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
self.tool_output_dir = self.session_dir / "tool-output"
|
|
311
|
+
self.tool_output_dir.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
self._active_procs: set[subprocess.Popen[str]] = set()
|
|
313
|
+
self._active_procs_lock = threading.Lock()
|
|
314
|
+
self.tool_specs = tuple(tools or DEFAULT_TOOL_SPECS)
|
|
315
|
+
self._tools_by_name: dict[str, ToolSpec] = {}
|
|
316
|
+
|
|
317
|
+
for spec in self.tool_specs:
|
|
318
|
+
if spec.name in self._tools_by_name:
|
|
319
|
+
raise ValueError(f"duplicate tool name: {spec.name}")
|
|
320
|
+
if not callable(getattr(self, spec.method_name, None)):
|
|
321
|
+
raise ValueError(f"missing tool method: {spec.method_name}")
|
|
322
|
+
self._tools_by_name[spec.name] = spec
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def definitions(self) -> list[dict[str, Any]]:
|
|
326
|
+
"""Return provider-facing tool definitions for the configured tools."""
|
|
327
|
+
|
|
328
|
+
return [
|
|
329
|
+
{
|
|
330
|
+
"name": spec.name,
|
|
331
|
+
"description": spec.description,
|
|
332
|
+
"input_schema": spec.input_schema,
|
|
333
|
+
}
|
|
334
|
+
for spec in self.tool_specs
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
def get_tool(self, name: str) -> ToolSpec | None:
|
|
338
|
+
"""Return the configured tool spec for a tool name."""
|
|
339
|
+
|
|
340
|
+
return self._tools_by_name.get(name)
|
|
341
|
+
|
|
342
|
+
def run(self, name: str, *, args: dict[str, Any]) -> ToolExecutionResult:
|
|
343
|
+
"""Execute a non-streaming tool call."""
|
|
344
|
+
|
|
345
|
+
spec = self.get_tool(name)
|
|
346
|
+
if spec is None:
|
|
347
|
+
return ToolExecutionResult(
|
|
348
|
+
model_text=f"error: unknown tool: {name}",
|
|
349
|
+
display_text=f"Unknown tool: {name}",
|
|
350
|
+
is_error=True,
|
|
351
|
+
)
|
|
352
|
+
if spec.streams_output:
|
|
353
|
+
raise ValueError(f"tool requires streaming execution: {name}")
|
|
354
|
+
|
|
355
|
+
handler = cast(Callable[..., ToolExecutionResult], getattr(self, spec.method_name))
|
|
356
|
+
return handler(**args)
|
|
357
|
+
|
|
358
|
+
def run_streaming(
|
|
359
|
+
self,
|
|
360
|
+
name: str,
|
|
361
|
+
*,
|
|
362
|
+
tool_call_id: str,
|
|
363
|
+
args: dict[str, Any],
|
|
364
|
+
on_output: ToolOutputCallback,
|
|
365
|
+
) -> ToolExecutionResult:
|
|
366
|
+
"""Execute a tool call that emits incremental output."""
|
|
367
|
+
|
|
368
|
+
spec = self.get_tool(name)
|
|
369
|
+
if spec is None:
|
|
370
|
+
return ToolExecutionResult(
|
|
371
|
+
model_text=f"error: unknown tool: {name}",
|
|
372
|
+
display_text=f"Unknown tool: {name}",
|
|
373
|
+
is_error=True,
|
|
374
|
+
)
|
|
375
|
+
if not spec.streams_output:
|
|
376
|
+
raise ValueError(f"tool does not support streaming execution: {name}")
|
|
377
|
+
|
|
378
|
+
handler = cast(Callable[..., ToolExecutionResult], getattr(self, spec.method_name))
|
|
379
|
+
return handler(tool_call_id=tool_call_id, on_output=on_output, **args)
|
|
380
|
+
|
|
381
|
+
def _track_proc(self, proc: subprocess.Popen[str]) -> None:
|
|
382
|
+
with self._active_procs_lock:
|
|
383
|
+
self._active_procs.add(proc)
|
|
384
|
+
with _ACTIVE_PROCS_LOCK:
|
|
385
|
+
_ACTIVE_PROCS.add(proc)
|
|
386
|
+
|
|
387
|
+
def _untrack_proc(self, proc: subprocess.Popen[str]) -> None:
|
|
388
|
+
with self._active_procs_lock:
|
|
389
|
+
self._active_procs.discard(proc)
|
|
390
|
+
with _ACTIVE_PROCS_LOCK:
|
|
391
|
+
_ACTIVE_PROCS.discard(proc)
|
|
392
|
+
|
|
393
|
+
def cancel_active(self) -> None:
|
|
394
|
+
"""Terminate only bash subprocesses started by this executor."""
|
|
395
|
+
|
|
396
|
+
with self._active_procs_lock:
|
|
397
|
+
procs = list(self._active_procs)
|
|
398
|
+
self._active_procs.clear()
|
|
399
|
+
|
|
400
|
+
for proc in procs:
|
|
401
|
+
with _ACTIVE_PROCS_LOCK:
|
|
402
|
+
_ACTIVE_PROCS.discard(proc)
|
|
403
|
+
_kill_proc_tree(proc)
|
|
404
|
+
|
|
405
|
+
# ---- read -----------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
def read(self, *, path: str, offset: int | None = None, limit: int | None = None) -> ToolExecutionResult:
|
|
408
|
+
"""Read a text file or supported image file.
|
|
409
|
+
|
|
410
|
+
offset is 1-indexed. limit is number of lines.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
file_path = Path(resolve_path(path, cwd=self.cwd))
|
|
414
|
+
if not file_path.exists():
|
|
415
|
+
return ToolExecutionResult(
|
|
416
|
+
model_text=f"error: file not found: {path}",
|
|
417
|
+
display_text=f"File not found: {path}",
|
|
418
|
+
is_error=True,
|
|
419
|
+
)
|
|
420
|
+
if not file_path.is_file():
|
|
421
|
+
return ToolExecutionResult(
|
|
422
|
+
model_text=f"error: not a file: {path}",
|
|
423
|
+
display_text=f"Not a file: {path}",
|
|
424
|
+
is_error=True,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
image_mime_type = detect_image_mime_type(file_path)
|
|
428
|
+
if image_mime_type:
|
|
429
|
+
if not self.supports_image_input:
|
|
430
|
+
return ToolExecutionResult(
|
|
431
|
+
model_text="error: image input is not supported by the current model",
|
|
432
|
+
display_text="Current model does not support image input",
|
|
433
|
+
is_error=True,
|
|
434
|
+
)
|
|
435
|
+
summary = f"Read image file [{image_mime_type}]"
|
|
436
|
+
image_data = b64encode(file_path.read_bytes()).decode("utf-8")
|
|
437
|
+
return ToolExecutionResult(
|
|
438
|
+
model_text=summary,
|
|
439
|
+
display_text=summary,
|
|
440
|
+
content=[
|
|
441
|
+
text_block(summary),
|
|
442
|
+
image_block(image_data, mime_type=image_mime_type, name=file_path.name),
|
|
443
|
+
],
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
start_line = offset if offset and offset > 0 else 1
|
|
447
|
+
line_limit = limit if limit and limit > 0 else DEFAULT_MAX_LINES
|
|
448
|
+
lines: list[str] = []
|
|
449
|
+
total_lines = 0
|
|
450
|
+
next_offset: int | None = None
|
|
451
|
+
first_shortened_line: int | None = None
|
|
452
|
+
shortened_lines = 0
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
with file_path.open("r", encoding="utf-8") as f:
|
|
456
|
+
for total_lines, raw_line in enumerate(f, start=1):
|
|
457
|
+
if total_lines < start_line:
|
|
458
|
+
continue
|
|
459
|
+
if len(lines) >= line_limit:
|
|
460
|
+
next_offset = total_lines
|
|
461
|
+
break
|
|
462
|
+
|
|
463
|
+
# Keep reads predictable: page by lines and only shorten pathological lines.
|
|
464
|
+
line = raw_line.rstrip("\r\n")
|
|
465
|
+
if len(line) > READ_MAX_LINE_CHARS:
|
|
466
|
+
if first_shortened_line is None:
|
|
467
|
+
first_shortened_line = total_lines
|
|
468
|
+
shortened_lines += 1
|
|
469
|
+
line = line[:READ_MAX_LINE_CHARS] + " ... [line truncated]"
|
|
470
|
+
lines.append(line)
|
|
471
|
+
except UnicodeDecodeError:
|
|
472
|
+
return ToolExecutionResult(
|
|
473
|
+
model_text=f"error: file is not valid utf-8 text: {path}",
|
|
474
|
+
display_text=f"File is not valid UTF-8 text: {path}",
|
|
475
|
+
is_error=True,
|
|
476
|
+
)
|
|
477
|
+
except Exception as exc:
|
|
478
|
+
return ToolExecutionResult(
|
|
479
|
+
model_text=f"error: failed to read file: {exc}",
|
|
480
|
+
display_text=f"Failed to read file: {path}",
|
|
481
|
+
is_error=True,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if total_lines < start_line and not (total_lines == 0 and start_line == 1):
|
|
485
|
+
return ToolExecutionResult(
|
|
486
|
+
model_text=f"error: offset {offset} beyond end of file ({total_lines} lines)",
|
|
487
|
+
display_text=f"Offset {offset} beyond end of file: {path}",
|
|
488
|
+
is_error=True,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
parts: list[str] = []
|
|
492
|
+
content = "\n".join(lines)
|
|
493
|
+
if content:
|
|
494
|
+
parts.append(content)
|
|
495
|
+
|
|
496
|
+
if next_offset is not None:
|
|
497
|
+
parts.append(f"[Showing lines {start_line}-{next_offset - 1}. Use offset={next_offset} to continue.]")
|
|
498
|
+
|
|
499
|
+
if first_shortened_line is not None:
|
|
500
|
+
quoted = shlex.quote(str(file_path))
|
|
501
|
+
prefix = f"[Line {first_shortened_line} was shortened to {READ_MAX_LINE_CHARS} chars."
|
|
502
|
+
if shortened_lines > 1:
|
|
503
|
+
prefix = (
|
|
504
|
+
f"[{shortened_lines} lines were shortened to {READ_MAX_LINE_CHARS} chars. "
|
|
505
|
+
f"First shortened line: {first_shortened_line}."
|
|
506
|
+
)
|
|
507
|
+
parts.append(
|
|
508
|
+
f"{prefix}\n"
|
|
509
|
+
"Use bash to inspect it in bytes:\n"
|
|
510
|
+
f"sed -n '{first_shortened_line}p' {quoted} | head -c 2000\n"
|
|
511
|
+
f"sed -n '{first_shortened_line}p' {quoted} | tail -c +2001 | head -c 2000]"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
content = "\n\n".join(parts) if parts else ""
|
|
515
|
+
return ToolExecutionResult(model_text=content, display_text=content)
|
|
516
|
+
|
|
517
|
+
# ---- write ----------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
def write(self, *, path: str, content: str) -> ToolExecutionResult:
|
|
520
|
+
file_path = Path(resolve_path(path, cwd=self.cwd))
|
|
521
|
+
try:
|
|
522
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
523
|
+
_atomic_write_text(file_path, content)
|
|
524
|
+
except Exception as exc:
|
|
525
|
+
return ToolExecutionResult(
|
|
526
|
+
model_text=f"error: failed to write file: {exc}",
|
|
527
|
+
display_text=f"Failed to write file: {path}",
|
|
528
|
+
is_error=True,
|
|
529
|
+
)
|
|
530
|
+
return ToolExecutionResult(model_text="ok", display_text=f"Wrote {path}")
|
|
531
|
+
|
|
532
|
+
# ---- edit -----------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
def edit(self, *, path: str, oldText: str, newText: str) -> ToolExecutionResult: # noqa: N803 (pi-compatible)
|
|
535
|
+
"""Replace one exact snippet, with a narrow fuzzy fallback.
|
|
536
|
+
|
|
537
|
+
The fallback only tolerates line-ending and trailing-whitespace changes.
|
|
538
|
+
It still requires a unique match so the edit stays deterministic.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
file_path = Path(resolve_path(path, cwd=self.cwd))
|
|
542
|
+
if not file_path.exists():
|
|
543
|
+
return ToolExecutionResult(
|
|
544
|
+
model_text=f"error: file not found: {path}",
|
|
545
|
+
display_text=f"File not found: {path}",
|
|
546
|
+
is_error=True,
|
|
547
|
+
)
|
|
548
|
+
if not file_path.is_file():
|
|
549
|
+
return ToolExecutionResult(
|
|
550
|
+
model_text=f"error: not a file: {path}",
|
|
551
|
+
display_text=f"Not a file: {path}",
|
|
552
|
+
is_error=True,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
try:
|
|
556
|
+
text = file_path.read_text(encoding="utf-8")
|
|
557
|
+
except Exception as exc:
|
|
558
|
+
return ToolExecutionResult(
|
|
559
|
+
model_text=f"error: failed to read file: {exc}",
|
|
560
|
+
display_text=f"Failed to read file: {path}",
|
|
561
|
+
is_error=True,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# Exact match first (deterministic and preferred)
|
|
565
|
+
exact_count = text.count(oldText)
|
|
566
|
+
match_pos: int | None = None
|
|
567
|
+
if exact_count == 1:
|
|
568
|
+
match_pos = text.index(oldText)
|
|
569
|
+
updated = text.replace(oldText, newText, 1)
|
|
570
|
+
elif exact_count > 1:
|
|
571
|
+
return ToolExecutionResult(
|
|
572
|
+
model_text=f"error: oldText occurs {exact_count} times; provide a more specific oldText",
|
|
573
|
+
display_text="Edit target is ambiguous; provide a more specific oldText",
|
|
574
|
+
is_error=True,
|
|
575
|
+
)
|
|
576
|
+
else:
|
|
577
|
+
# Conservative fuzzy fallback:
|
|
578
|
+
# tolerate line-ending and trailing-whitespace differences only.
|
|
579
|
+
fuzzy_span, fuzzy_count = _find_fuzzy_edit_span(text, oldText)
|
|
580
|
+
if fuzzy_span is None:
|
|
581
|
+
if fuzzy_count > 1:
|
|
582
|
+
return ToolExecutionResult(
|
|
583
|
+
model_text=(
|
|
584
|
+
f"error: oldText occurs {fuzzy_count} times after normalization; "
|
|
585
|
+
"provide a more specific oldText"
|
|
586
|
+
),
|
|
587
|
+
display_text="Edit target is ambiguous after normalization",
|
|
588
|
+
is_error=True,
|
|
589
|
+
)
|
|
590
|
+
hint = _closest_line_hint(text, oldText)
|
|
591
|
+
if hint:
|
|
592
|
+
return ToolExecutionResult(
|
|
593
|
+
model_text=f"error: oldText not found. closest line: {hint}",
|
|
594
|
+
display_text="Edit target not found",
|
|
595
|
+
is_error=True,
|
|
596
|
+
)
|
|
597
|
+
return ToolExecutionResult(
|
|
598
|
+
model_text="error: oldText not found",
|
|
599
|
+
display_text="Edit target not found",
|
|
600
|
+
is_error=True,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
start, end = fuzzy_span
|
|
604
|
+
match_pos = start
|
|
605
|
+
updated = text[:start] + newText + text[end:]
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
_atomic_write_text(file_path, updated)
|
|
609
|
+
except Exception as exc:
|
|
610
|
+
return ToolExecutionResult(
|
|
611
|
+
model_text=f"error: failed to write file: {exc}",
|
|
612
|
+
display_text=f"Failed to write file: {path}",
|
|
613
|
+
is_error=True,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# Return a compact JSON payload so the frontend can render a focused diff
|
|
617
|
+
# around the edited range without re-reading the whole file.
|
|
618
|
+
if match_pos is None:
|
|
619
|
+
match_pos = text.index(oldText)
|
|
620
|
+
|
|
621
|
+
start_line = text[:match_pos].count("\n") + 1
|
|
622
|
+
old_line_count = len(oldText.splitlines()) or 1
|
|
623
|
+
new_line_count = len(newText.splitlines()) or 1
|
|
624
|
+
context_lines = 3
|
|
625
|
+
lines = updated.splitlines()
|
|
626
|
+
edit_start = start_line - 1
|
|
627
|
+
before = lines[max(0, edit_start - context_lines) : edit_start]
|
|
628
|
+
after = lines[edit_start + new_line_count : edit_start + new_line_count + context_lines]
|
|
629
|
+
|
|
630
|
+
return ToolExecutionResult(
|
|
631
|
+
model_text=json.dumps(
|
|
632
|
+
{
|
|
633
|
+
"status": "ok",
|
|
634
|
+
"start_line": start_line,
|
|
635
|
+
"old_line_count": old_line_count,
|
|
636
|
+
"new_line_count": new_line_count,
|
|
637
|
+
"context_before": before,
|
|
638
|
+
"context_after": after,
|
|
639
|
+
}
|
|
640
|
+
),
|
|
641
|
+
display_text=f"Updated {path}",
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
# ---- bash -----------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
def bash(
|
|
647
|
+
self,
|
|
648
|
+
*,
|
|
649
|
+
tool_call_id: str,
|
|
650
|
+
command: str,
|
|
651
|
+
timeout: int | None = None,
|
|
652
|
+
on_output: ToolOutputCallback | None = None,
|
|
653
|
+
) -> ToolExecutionResult:
|
|
654
|
+
"""Run a shell command and return combined stdout/stderr text.
|
|
655
|
+
|
|
656
|
+
Output is streamed line-by-line through ``on_output`` when provided. If
|
|
657
|
+
the output grows too large for memory or needs truncation, the full log
|
|
658
|
+
is written under the session's ``tool-output/`` directory.
|
|
659
|
+
"""
|
|
660
|
+
|
|
661
|
+
timeout_seconds = int(timeout or BASH_TIMEOUT_SECONDS)
|
|
662
|
+
if timeout_seconds <= 0:
|
|
663
|
+
timeout_seconds = BASH_TIMEOUT_SECONDS
|
|
664
|
+
|
|
665
|
+
proc: subprocess.Popen[str] | None = None
|
|
666
|
+
log_path = self.tool_output_dir / f"bash-{tool_call_id}.log"
|
|
667
|
+
kept_lines: list[str] = []
|
|
668
|
+
kept_bytes = 0
|
|
669
|
+
tail_lines: deque[str] = deque(maxlen=DEFAULT_MAX_LINES)
|
|
670
|
+
log_file: TextIO | None = None
|
|
671
|
+
saved_output_path: Path | None = None
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
proc = subprocess.Popen(
|
|
675
|
+
command,
|
|
676
|
+
shell=True,
|
|
677
|
+
cwd=self.cwd,
|
|
678
|
+
stdout=subprocess.PIPE,
|
|
679
|
+
stderr=subprocess.STDOUT,
|
|
680
|
+
text=True,
|
|
681
|
+
bufsize=1,
|
|
682
|
+
universal_newlines=True,
|
|
683
|
+
start_new_session=os.name == "posix",
|
|
684
|
+
)
|
|
685
|
+
self._track_proc(proc)
|
|
686
|
+
|
|
687
|
+
stdout = cast(TextIO, proc.stdout)
|
|
688
|
+
output_queue: queue.Queue[str | None] = queue.Queue()
|
|
689
|
+
reader_errors: list[Exception] = []
|
|
690
|
+
|
|
691
|
+
def read_stdout() -> None:
|
|
692
|
+
try:
|
|
693
|
+
for line in stdout:
|
|
694
|
+
output_queue.put(line)
|
|
695
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
696
|
+
reader_errors.append(exc)
|
|
697
|
+
finally:
|
|
698
|
+
output_queue.put(None)
|
|
699
|
+
|
|
700
|
+
reader = threading.Thread(target=read_stdout, daemon=True)
|
|
701
|
+
reader.start()
|
|
702
|
+
deadline = time.monotonic() + timeout_seconds
|
|
703
|
+
|
|
704
|
+
while True:
|
|
705
|
+
remaining = deadline - time.monotonic()
|
|
706
|
+
if remaining <= 0:
|
|
707
|
+
_kill_proc_tree(proc)
|
|
708
|
+
return ToolExecutionResult(
|
|
709
|
+
model_text=f"error: timeout after {timeout_seconds}s",
|
|
710
|
+
display_text=f"Command timed out after {timeout_seconds}s",
|
|
711
|
+
is_error=True,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
try:
|
|
715
|
+
line = output_queue.get(timeout=min(0.1, remaining))
|
|
716
|
+
except queue.Empty:
|
|
717
|
+
continue
|
|
718
|
+
|
|
719
|
+
if line is None:
|
|
720
|
+
break
|
|
721
|
+
|
|
722
|
+
line = line.rstrip("\n")
|
|
723
|
+
kept_bytes += len((line + "\n").encode("utf-8"))
|
|
724
|
+
|
|
725
|
+
if log_file is None:
|
|
726
|
+
kept_lines.append(line)
|
|
727
|
+
if kept_bytes > _BASH_MAX_IN_MEMORY_BYTES:
|
|
728
|
+
log_file = log_path.open("w", encoding="utf-8")
|
|
729
|
+
saved_output_path = log_path
|
|
730
|
+
if kept_lines:
|
|
731
|
+
log_file.write("\n".join(kept_lines))
|
|
732
|
+
log_file.write("\n")
|
|
733
|
+
tail_lines.extend(kept_lines)
|
|
734
|
+
kept_lines = []
|
|
735
|
+
else:
|
|
736
|
+
tail_lines.append(line)
|
|
737
|
+
log_file.write(line)
|
|
738
|
+
log_file.write("\n")
|
|
739
|
+
|
|
740
|
+
if on_output:
|
|
741
|
+
on_output(line)
|
|
742
|
+
|
|
743
|
+
if reader_errors:
|
|
744
|
+
message = str(reader_errors[0])
|
|
745
|
+
return ToolExecutionResult(
|
|
746
|
+
model_text=f"error: {message}",
|
|
747
|
+
display_text=message,
|
|
748
|
+
is_error=True,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
remaining = max(0.1, deadline - time.monotonic())
|
|
753
|
+
proc.wait(timeout=remaining)
|
|
754
|
+
except subprocess.TimeoutExpired:
|
|
755
|
+
_kill_proc_tree(proc)
|
|
756
|
+
return ToolExecutionResult(
|
|
757
|
+
model_text=f"error: timeout after {timeout_seconds}s",
|
|
758
|
+
display_text=f"Command timed out after {timeout_seconds}s",
|
|
759
|
+
is_error=True,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
raw_output = "\n".join(list(tail_lines) if log_file is not None else kept_lines)
|
|
763
|
+
output = raw_output.strip() or "(empty)"
|
|
764
|
+
content, trunc = truncate_text(output, tail=True)
|
|
765
|
+
|
|
766
|
+
if log_file is None and trunc.truncated:
|
|
767
|
+
try:
|
|
768
|
+
log_path.write_text(raw_output, encoding="utf-8")
|
|
769
|
+
saved_output_path = log_path
|
|
770
|
+
except Exception:
|
|
771
|
+
saved_output_path = None
|
|
772
|
+
|
|
773
|
+
if log_file is not None or trunc.truncated:
|
|
774
|
+
result = content
|
|
775
|
+
if log_file is not None:
|
|
776
|
+
result += "\n\n[Output truncated in memory.]"
|
|
777
|
+
else:
|
|
778
|
+
result += "\n\n[Output truncated.]"
|
|
779
|
+
|
|
780
|
+
if saved_output_path is not None:
|
|
781
|
+
result += f" Full output saved to: {saved_output_path}. Use read with offset/limit."
|
|
782
|
+
if not content:
|
|
783
|
+
quoted = shlex.quote(str(saved_output_path))
|
|
784
|
+
result += (
|
|
785
|
+
"\nUse bash to inspect bytes:\n"
|
|
786
|
+
f"head -c 2000 {quoted}\n"
|
|
787
|
+
f"tail -c +2001 {quoted} | head -c 2000"
|
|
788
|
+
)
|
|
789
|
+
return ToolExecutionResult(model_text=result, display_text=result)
|
|
790
|
+
|
|
791
|
+
return ToolExecutionResult(model_text=content, display_text=content)
|
|
792
|
+
|
|
793
|
+
except Exception as exc:
|
|
794
|
+
message = str(exc)
|
|
795
|
+
return ToolExecutionResult(
|
|
796
|
+
model_text=f"error: {message}",
|
|
797
|
+
display_text=message,
|
|
798
|
+
is_error=True,
|
|
799
|
+
)
|
|
800
|
+
finally:
|
|
801
|
+
if log_file is not None:
|
|
802
|
+
try:
|
|
803
|
+
log_file.close()
|
|
804
|
+
except Exception:
|
|
805
|
+
pass
|
|
806
|
+
if proc:
|
|
807
|
+
self._untrack_proc(proc)
|
|
808
|
+
if proc.poll() is None:
|
|
809
|
+
_kill_proc_tree(proc)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _closest_line_hint(text: str, needle: str) -> str | None:
|
|
813
|
+
needle_clean = needle.strip()
|
|
814
|
+
if not needle_clean:
|
|
815
|
+
return None
|
|
816
|
+
|
|
817
|
+
best_ratio = 0.0
|
|
818
|
+
best_line = ""
|
|
819
|
+
for line in text.splitlines():
|
|
820
|
+
candidate = line.strip()
|
|
821
|
+
if not candidate:
|
|
822
|
+
continue
|
|
823
|
+
ratio = SequenceMatcher(None, needle_clean, candidate).ratio()
|
|
824
|
+
if ratio > best_ratio:
|
|
825
|
+
best_ratio = ratio
|
|
826
|
+
best_line = candidate
|
|
827
|
+
|
|
828
|
+
if best_ratio < 0.6 or not best_line:
|
|
829
|
+
return None
|
|
830
|
+
|
|
831
|
+
if len(best_line) > 120:
|
|
832
|
+
return best_line[:117] + "..."
|
|
833
|
+
return best_line
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _find_fuzzy_edit_span(text: str, old_text: str) -> tuple[tuple[int, int] | None, int]:
|
|
837
|
+
"""Find unique match span with conservative normalization.
|
|
838
|
+
|
|
839
|
+
Normalization is intentionally limited to:
|
|
840
|
+
- line ending normalization (CRLF/CR -> LF)
|
|
841
|
+
- trailing space/tab removal per line
|
|
842
|
+
"""
|
|
843
|
+
|
|
844
|
+
normalized_text, text_map = _normalize_for_fuzzy_edit(text)
|
|
845
|
+
normalized_old, _ = _normalize_for_fuzzy_edit(old_text)
|
|
846
|
+
|
|
847
|
+
first = normalized_text.find(normalized_old)
|
|
848
|
+
if first == -1:
|
|
849
|
+
return None, 0
|
|
850
|
+
|
|
851
|
+
count = normalized_text.count(normalized_old)
|
|
852
|
+
if count != 1:
|
|
853
|
+
return None, count
|
|
854
|
+
|
|
855
|
+
end_normalized = first + len(normalized_old)
|
|
856
|
+
start_original = text_map[first]
|
|
857
|
+
end_original = text_map[end_normalized] if end_normalized < len(text_map) else len(text)
|
|
858
|
+
return (start_original, end_original), 1
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _normalize_for_fuzzy_edit(text: str) -> tuple[str, list[int]]:
|
|
862
|
+
"""Normalize text for conservative fuzzy edit matching.
|
|
863
|
+
|
|
864
|
+
Returns normalized text plus a map from normalized index -> original index.
|
|
865
|
+
"""
|
|
866
|
+
|
|
867
|
+
chars: list[str] = []
|
|
868
|
+
index_map: list[int] = []
|
|
869
|
+
|
|
870
|
+
i = 0
|
|
871
|
+
n = len(text)
|
|
872
|
+
while i < n:
|
|
873
|
+
line_start = i
|
|
874
|
+
while i < n and text[i] not in ("\n", "\r"):
|
|
875
|
+
i += 1
|
|
876
|
+
|
|
877
|
+
line_end = i
|
|
878
|
+
trimmed_end = line_end
|
|
879
|
+
while trimmed_end > line_start and text[trimmed_end - 1] in (" ", "\t"):
|
|
880
|
+
trimmed_end -= 1
|
|
881
|
+
|
|
882
|
+
for pos in range(line_start, trimmed_end):
|
|
883
|
+
chars.append(text[pos])
|
|
884
|
+
index_map.append(pos)
|
|
885
|
+
|
|
886
|
+
if i >= n:
|
|
887
|
+
continue
|
|
888
|
+
|
|
889
|
+
# Normalize any line ending to LF and map it to the original EOL start index.
|
|
890
|
+
eol_start = i
|
|
891
|
+
if text[i] == "\r" and i + 1 < n and text[i + 1] == "\n":
|
|
892
|
+
i += 2
|
|
893
|
+
else:
|
|
894
|
+
i += 1
|
|
895
|
+
chars.append("\n")
|
|
896
|
+
index_map.append(eol_start)
|
|
897
|
+
|
|
898
|
+
return "".join(chars), index_map
|