Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline ac384f3d27 feat(opencode): add experimental code-mode MCP adapter 2026-07-03 00:17:49 -05:00
Aiden Cline 92a50f2397 refactor(codemode): clean up package comments 2026-07-03 00:08:01 -05:00
Aiden Cline ac7ee99a24 feat(codemode): add confined execution package 2026-07-03 00:01:06 -05:00
107 changed files with 2753 additions and 6454 deletions
-3
View File
@@ -1,3 +0,0 @@
node_modules
.remotion
out/frame-*.png
-483
View File
@@ -1,483 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "glm52-rise-video",
"dependencies": {
"@remotion/cli": "^4.0.384",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"remotion": "^4.0.384",
},
"devDependencies": {
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"typescript": "^5.8.2",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.24.1", "", { "bin": "./bin/babel-parser.js" }, "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg=="],
"@babel/types": ["@babel/types@7.24.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w=="],
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JNzgdJoHMFFnv5imi1+dmjZMudsJ1zNCUCEkKjBh90cqGhAFg8xu4V2gsyxE2i6oq/YpWx23P+OvoANLSMcDzA=="],
"@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-VpKmJO0xlYcFCRD6JvJlMbNQ6d/6YMHdO1gFIqWlZABHjSSL6BquNNEgWSCv5vdF8ELDBwIYBVZEslakIB/7GA=="],
"@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JyzZyGeGRm2HVUQaGJ/VZT9OG+kG00mA8SLP/f3CO7+qWAmBncKc16WYXWHbZEo8Jn/ZGA6De32S5R2bTQbDqA=="],
"@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="],
"@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="],
"@module-federation/runtime-core": ["@module-federation/runtime-core@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA=="],
"@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/webpack-bundler-runtime": "0.22.0" } }, "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA=="],
"@module-federation/sdk": ["@module-federation/sdk@0.22.0", "", {}, "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g=="],
"@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@remotion/bundler": ["@remotion/bundler@4.0.483", "", { "dependencies": { "@remotion/media-parser": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@rspack/core": "1.7.11", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.1", "react-refresh": "0.18.0", "remotion": "4.0.483", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-5fQRMYgr2sxZPBOThnIVuFwHBq/FhusJ+Ke6xopga8io8ecK/mj9GaRXC3tgfMa+YWuu8ZSXZ0U9EsMZjjP5YQ=="],
"@remotion/canvas-capture": ["@remotion/canvas-capture@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JABgbfvXwjFp1E61tlDN9gVAH8OoeF4kUUELNwG9kcNOE4ex8+vCYEO6VKIaXZ4l8A1PEAQ5EK//NheTJ80gfw=="],
"@remotion/cli": ["@remotion/cli@4.0.483", "", { "dependencies": { "@remotion/bundler": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-server": "4.0.483", "@remotion/studio-shared": "4.0.483", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-z4TQcgTfQbSIV4GQpCvIihDL77M+leQelbxv962PmfSKTT7z3XnNN732PcZ4sei2+Xg+d2SD95K4tI5XBYs5Gw=="],
"@remotion/compositor-darwin-arm64": ["@remotion/compositor-darwin-arm64@4.0.483", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CkEJXouGEoCs5PX3KOufkXrlT/Kgn0ZF7SEQ4meCYGYMZD+QPVtsmS1MXH8pQ4RHlFO6Tk7g0CIHMT3Y4yzpuA=="],
"@remotion/compositor-darwin-x64": ["@remotion/compositor-darwin-x64@4.0.483", "", { "os": "darwin", "cpu": "x64" }, "sha512-vDZK5U8FYbHGMjFC2lUgJRBz8KXwbwFSvEQi8FATD0roNg75SoaEJWrqAOIVv8v5eBjPvo18znDc36NLRYn1Eg=="],
"@remotion/compositor-linux-arm64-gnu": ["@remotion/compositor-linux-arm64-gnu@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-1LzfYDY/DQxy3MIc9pCbTn1ObcBDcyI6MzODOwe1bTZl8TZxdUUt1M3hDrQ60AB6iHwruBAnzT9dnogiMIdSew=="],
"@remotion/compositor-linux-arm64-musl": ["@remotion/compositor-linux-arm64-musl@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-nG1btxg0HXuLzulIqAg9NnxWyewZNrA0iLe82HdniaJjeWLORreGo4cEEJz1SjbDpQuPEY562XGkTO2DLKK5mA=="],
"@remotion/compositor-linux-x64-gnu": ["@remotion/compositor-linux-x64-gnu@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-CV4h6r2rNYh4P8utUzP6RM2vW2vq43nA3IvVOwRSuh9LwJh/7rwG53Oco4HanY/sstaNuymGpT57wdXwONU5CQ=="],
"@remotion/compositor-linux-x64-musl": ["@remotion/compositor-linux-x64-musl@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-moKvavbkoz5nteJZA4K6LMgTTlewhwIbtO7DYARZ3LK3ClGUKnYfFcmaPS15hMun02JPTOMIWbrlaszdR75mxA=="],
"@remotion/compositor-win32-x64-msvc": ["@remotion/compositor-win32-x64-msvc@4.0.483", "", { "os": "win32", "cpu": "x64" }, "sha512-eDpWe6Vy7ySHugwxlgaeohqdBwR1etxiymXzT13cgbPw2+p6d55z1tJ7VPTXyfu0o+UdyRTauWqD4msoxN0wow=="],
"@remotion/licensing": ["@remotion/licensing@4.0.483", "", {}, "sha512-GRtjykrxMmB5Cjpq1oVqeYIRXI95vid6afX0LTLfp1shD6MR4lTZRoRuopxrrZp1E4XFGXoY64b6dlIudgTyBQ=="],
"@remotion/media-parser": ["@remotion/media-parser@4.0.483", "", {}, "sha512-iva9Eof7QQX+3hGxH/N8pGi80wgdGuzOr825Zn7IuEe7IWdtRYWlFpCEghmyfhrwgaWa7RY51wg77ycR3c3nRg=="],
"@remotion/media-utils": ["@remotion/media-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4T4WQh/U95kCBzgmz/EL/HL8Zeox/AKAL5ufsehrZFgwlcvH1EsKNJH/RWaAtNPRLF+pskEOgFzPYaczHeI8hg=="],
"@remotion/player": ["@remotion/player@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yyIQ9vinUqqS0fQeL52bPCWQIr8qDXCqQ4iys8P7dSZncdu8V29p4W2a0/4eKV8bv5+mjnJQ/U6nl9NxBF1hdA=="],
"@remotion/renderer": ["@remotion/renderer@4.0.483", "", { "dependencies": { "@remotion/licensing": "4.0.483", "@remotion/streaming": "4.0.483", "execa": "5.1.1", "remotion": "4.0.483", "source-map": "0.8.0-beta.0", "ws": "8.21.0" }, "optionalDependencies": { "@remotion/compositor-darwin-arm64": "4.0.483", "@remotion/compositor-darwin-x64": "4.0.483", "@remotion/compositor-linux-arm64-gnu": "4.0.483", "@remotion/compositor-linux-arm64-musl": "4.0.483", "@remotion/compositor-linux-x64-gnu": "4.0.483", "@remotion/compositor-linux-x64-musl": "4.0.483", "@remotion/compositor-win32-x64-msvc": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-eSKdQqm8rcl+GNyOhFsBgcPYWMcxTJWHL5xaNrd037piCaL3sdJ57OEO/AbfEvvOO14zwUvlsONKjlkLlEVyKg=="],
"@remotion/streaming": ["@remotion/streaming@4.0.483", "", {}, "sha512-96rqrk+l5AfriHOqmqxMn8rI6UJfFY7r5UMDZoSKqXWzTOwj+0VWHTRl9ilZQMXhYACaMSA/5yrL9jgQEK8Yuw=="],
"@remotion/studio": ["@remotion/studio@4.0.483", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@remotion/canvas-capture": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@remotion/web-renderer": "4.0.483", "@remotion/zod-types": "4.0.483", "mediabunny": "1.47.0", "memfs": "3.4.3", "open": "8.4.2", "remotion": "4.0.483", "semver": "7.5.3", "zod": "4.3.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-gNa1Lw8NmHl0jwFb5fk7rzA+WFTJQpGDT/49A38t9jpEwoF+h1mKTgY8gA202apzjBHrEECdFE450npnBOUpUw=="],
"@remotion/studio-server": ["@remotion/studio-server@4.0.483", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/bundler": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "memfs": "3.4.3", "open": "8.4.2", "prettier": "3.8.1", "recast": "0.23.11", "remotion": "4.0.483", "semver": "7.5.3" } }, "sha512-2FSLbVN5N8bgtQPX+RQRbis557tJJnVTOZuEBZndfC9NOoPdARlSgEnwVer9tBNczUlc6B3KrPxyPSlS3UjL+Q=="],
"@remotion/studio-shared": ["@remotion/studio-shared@4.0.483", "", { "dependencies": { "remotion": "4.0.483" } }, "sha512-aXxBYUgsWgphHVq7T7bz+ICMGzfii7pcX1IxS5hbLMLJHqlDtk9g+qcxm4kJ2Fjj/Gb5gERKiNzYym8WZTIM2Q=="],
"@remotion/timeline-utils": ["@remotion/timeline-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0" } }, "sha512-KTTMdpNA5uRLX+iisAITO+V9cJBk17F8EWvuUnAEVmAFOQTbGIyPI9HNw5pZmL7SlzOJdFjAtksTjipeYGkEug=="],
"@remotion/web-renderer": ["@remotion/web-renderer@4.0.483", "", { "dependencies": { "@mediabunny/aac-encoder": "1.47.0", "@mediabunny/flac-encoder": "1.47.0", "@mediabunny/mp3-encoder": "1.47.0", "@remotion/licensing": "4.0.483", "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-14iURfVWA/hcF/rJvkid1ZR5nmnVhkTIf071FabCFi6kdViOSVBugSglJ8tUPvWVbWmxlXGDGQD9CSU18IATww=="],
"@remotion/zod-types": ["@remotion/zod-types@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "zod": "4.3.6" } }, "sha512-KIVYIzFpZBDB0wxGGQu5tDBr4XOYRzvrPXjmo6O/FI5Xx7O8hgvJmBs9VDkEwonAOft86sg79hI19F1DTX1p4w=="],
"@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="],
"@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="],
"@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="],
"@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="],
"@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="],
"@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="],
"@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="],
"@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="],
"@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="],
"@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="],
"@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="],
"@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="],
"@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="],
"@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@1.6.1", "", { "dependencies": { "error-stack-parser": "^2.1.4", "html-entities": "^2.6.0" }, "peerDependencies": { "react-refresh": ">=0.10.0 <1.0.0", "webpack-hot-middleware": "2.x" }, "optionalPeers": ["webpack-hot-middleware"] }, "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="],
"@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
"@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="],
"@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="],
"@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="],
"@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="],
"@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="],
"@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="],
"@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="],
"@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="],
"@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="],
"@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="],
"@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="],
"@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="],
"@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="],
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
"ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="],
"browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="],
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-loader": ["css-loader@7.1.4", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.6.3" }, "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.379", "", {}, "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA=="],
"enhanced-resolve": ["enhanced-resolve@5.24.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw=="],
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fs-monkey": ["fs-monkey@1.0.3", "", {}, "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q=="],
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="],
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"mediabunny": ["mediabunny@1.47.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-XQMZAcaKPkJ7hQ/Q2fvBdl3ZazQl2WVxDysUbJWh4PuAnLoerdsQBdPTDWdUdK6hh26LQ8Ue94MLLnmpWvlUYg=="],
"memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="],
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="],
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="],
"postcss-modules-local-by-default": ["postcss-modules-local-by-default@4.2.0", "", { "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw=="],
"postcss-modules-scope": ["postcss-modules-scope@3.2.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA=="],
"postcss-modules-values": ["postcss-modules-values@4.0.0", "", { "dependencies": { "icss-utils": "^5.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"remotion": ["remotion@4.0.483", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-lf4xq4Twn75TQeTavFLrTE4zdck7EKBSx07ZrPv4om40Sist+3G0kq7NNzSjMeJ6G2Wx6vI+0oICD/vv4sGWtg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
"semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="],
"terser-webpack-plugin": ["terser-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="],
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"webpack": ["webpack@5.105.0", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw=="],
"webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="],
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"css-loader/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
{
"name": "glm52-rise-video",
"private": true,
"type": "module",
"scripts": {
"render": "remotion render src/index.tsx GLM52Rise out/glm-52-broke-out.mp4 --codec h264 --pixel-format yuv420p",
"render:sheep": "remotion render src/index.tsx NZSheep out/nz-sheep.mp4 --codec h264 --pixel-format yuv420p",
"render:novel": "remotion render src/index.tsx NovelTokens out/novel-1984.mp4 --codec h264 --pixel-format yuv420p",
"render:flash": "remotion render src/index.tsx FlashShare out/flash-share.mp4 --codec h264 --pixel-format yuv420p",
"render:minimax": "remotion render src/index.tsx MiniMaxClimb out/minimax-climb.mp4 --codec h264 --pixel-format yuv420p",
"still:june": "remotion still src/index.tsx JuneTotals out/june-totals.png --frame=0"
},
"dependencies": {
"@remotion/cli": "^4.0.384",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"remotion": "^4.0.384"
},
"devDependencies": {
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"typescript": "^5.8.2"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

-156
View File
@@ -1,156 +0,0 @@
// Verified against the production opencode-stats PlanetScale DB.
// Scope: tier='Go' (OpenCode Go), dataset='zen', client='all', source='all', grain='day'.
// metric = total_tokens. Daily token volume per model; GLM-5.2 (zhipu) launched Jun 17.
// Segments per day (tokens): glm-5.2, deepseek-v4-flash, deepseek-v4-pro, minimax-m3, all others.
export type Day = {
date: string
total: number
glm: number
dsf: number
dsp: number
mm: number
others: number
}
export const days: Day[] = [
{
date: "Jun 12",
total: 2_283_799_449_383,
glm: 0,
dsf: 1_176_701_653_509,
dsp: 569_527_034_307,
mm: 159_016_250_684,
others: 378_554_510_883,
},
{
date: "Jun 13",
total: 2_008_462_388_420,
glm: 0,
dsf: 995_338_131_997,
dsp: 445_817_536_548,
mm: 211_743_241_967,
others: 355_563_477_908,
},
{
date: "Jun 14",
total: 2_007_785_405_251,
glm: 0,
dsf: 983_954_176_228,
dsp: 428_151_999_341,
mm: 262_476_527_930,
others: 333_202_701_752,
},
{
date: "Jun 15",
total: 2_694_736_103_062,
glm: 0,
dsf: 1_255_893_953_859,
dsp: 632_223_338_376,
mm: 352_507_442_991,
others: 454_111_367_836,
},
{
date: "Jun 16",
total: 2_838_153_758_908,
glm: 0,
dsf: 1_336_625_283_800,
dsp: 676_480_415_730,
mm: 305_268_829_013,
others: 519_779_230_365,
},
{
date: "Jun 17",
total: 2_778_964_711_109,
glm: 70_095_977_043,
dsf: 1_339_831_523_555,
dsp: 660_414_395_220,
mm: 251_302_096_157,
others: 457_320_719_134,
},
{
date: "Jun 18",
total: 2_806_992_430_656,
glm: 201_130_231_172,
dsf: 1_295_599_996_869,
dsp: 595_665_008_776,
mm: 322_205_104_324,
others: 392_392_089_515,
},
{
date: "Jun 19",
total: 2_419_611_630_232,
glm: 199_086_413_910,
dsf: 1_115_750_468_802,
dsp: 475_965_869_304,
mm: 303_586_698_735,
others: 325_222_179_481,
},
{
date: "Jun 20",
total: 2_188_278_916_865,
glm: 193_931_516_396,
dsf: 1_050_194_681_012,
dsp: 395_303_435_278,
mm: 281_998_000_337,
others: 266_851_283_842,
},
{
date: "Jun 21",
total: 2_042_309_961_344,
glm: 181_894_043_118,
dsf: 985_164_570_580,
dsp: 368_194_079_542,
mm: 259_812_551_324,
others: 247_244_716_780,
},
{
date: "Jun 22",
total: 2_893_934_325_663,
glm: 301_759_048_475,
dsf: 1_298_124_282_989,
dsp: 581_012_596_194,
mm: 371_581_117_839,
others: 341_457_280_166,
},
{
date: "Jun 23",
total: 3_109_009_321_480,
glm: 282_277_235_158,
dsf: 1_423_571_678_821,
dsp: 627_374_654_587,
mm: 429_416_300_508,
others: 346_369_452_406,
},
{
date: "Jun 24",
total: 2_939_149_971_595,
glm: 256_497_442_533,
dsf: 1_373_583_023_234,
dsp: 601_270_997_775,
mm: 391_586_493_231,
others: 316_212_014_822,
},
{
date: "Jun 25",
total: 3_029_641_552_948,
glm: 256_279_657_734,
dsf: 1_481_084_002_776,
dsp: 602_077_167_287,
mm: 375_985_302_874,
others: 314_215_422_277,
},
]
export const launchIndex = 5 // Jun 17, first day of GLM-5.2 usage
// GLM-5.2 weekly token volume, Jun 19-25 (sum of glm): 1,671,725,357,324 = 1.67T
export const glmWeekTokensT = 1.672
// stacked segments, bottom -> top. GLM-5.2 is the hero (blue); the rest are the field it cut into.
export const segments = [
{ key: "glm", label: "GLM-5.2", color: "#3b5cf6", hero: true },
{ key: "dsf", label: "deepseek-v4-flash", color: "#9ca3ad" },
{ key: "dsp", label: "deepseek-v4-pro", color: "#b3b9c1" },
{ key: "mm", label: "minimax-m3", color: "#c8cdd3" },
{ key: "others", label: "other models", color: "#dde0e4" },
] as const
-185
View File
@@ -1,185 +0,0 @@
import React from "react"
import { AbsoluteFill, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"
// stats.opencode.ai design tokens (light theme)
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#e4e4e4",
gray: "#aab0b8",
accent: "#3b5cf6",
accentHi: "#5b78ff",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26). share = % of 19.64T total Go tokens.
const bars = [
{ label: "deepseek-v4-flash", share: 48.3, hero: true },
{ label: "deepseek-v4-pro", share: 19.4 },
{ label: "minimax-m3", share: 13.0 },
{ label: "glm-5.2", share: 8.3 },
{ label: "mimo-v2.5", share: 4.3 },
{ label: "kimi-k2.7-code", share: 2.6 },
{ label: "other models", share: 4.1 },
]
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function FlashShare() {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const grow = (i: number) =>
Math.min(
1,
Math.max(0, spring({ frame: frame - 18 - i * 7, fps, config: { damping: 18, stiffness: 120, mass: 0.6 } })),
)
return (
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 2228, 2026</div>
</div>
{/* headline (static) */}
<div style={{ marginTop: 50 }}>
<div style={{ fontSize: 23, fontWeight: 600, color: c.muted, letterSpacing: 2 }}>
OPENCODE GO · SHARE OF TOKENS
</div>
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 24, marginTop: 14 }}
>
<div style={{ fontSize: 62, fontWeight: 600, letterSpacing: -2, lineHeight: 1 }}>DeepSeek V4 Flash</div>
<div
style={{
fontSize: 88,
fontWeight: 600,
letterSpacing: -2,
lineHeight: 1,
color: c.accent,
fontVariantNumeric: "tabular-nums",
}}
>
48%
</div>
</div>
</div>
{/* bar chart */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", gap: 16 }}>
{bars.map((b, i) => {
const g = grow(i)
const pct = b.share * g
return (
<div key={b.label} style={{ display: "flex", alignItems: "center", gap: 18 }}>
<div
style={{
width: 268,
fontSize: 23,
fontWeight: b.hero ? 600 : 500,
color: b.hero ? c.ink : c.muted,
textAlign: "right",
whiteSpace: "nowrap",
}}
>
{b.label}
</div>
<div style={{ position: "relative", flex: 1, height: 48 }}>
{/* dotted 100% track — height is a multiple of the 12px tile, anchored bottom, so dots never clip */}
<div
style={{
position: "absolute",
inset: 0,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
WebkitMaskPosition: "left bottom",
maskPosition: "left bottom",
}}
/>
{/* fill */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: `${pct}%`,
background: b.hero ? c.accent : c.gray,
borderRight: b.hero ? `2px solid ${c.accentHi}` : "none",
}}
/>
</div>
<div
style={{
width: 84,
fontSize: 24,
fontWeight: b.hero ? 600 : 500,
color: b.hero ? c.accent : c.muted,
textAlign: "right",
fontVariantNumeric: "tabular-nums",
}}
>
{Math.round(pct)}%
</div>
</div>
)
})}
</div>
{/* footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 24,
paddingTop: 22,
borderTop: `1px solid ${c.line}`,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
DeepSeek V4 Flash · 9.48T tokens · 83.6M requests
</div>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</AbsoluteFill>
)
}
-36
View File
@@ -1,36 +0,0 @@
import { Composition, registerRoot } from "remotion"
import { GLM52Rise } from "./video"
import { NZSheep } from "./sheep"
import { NovelTokens } from "./novel"
import { FlashShare } from "./flash"
import { MiniMaxClimb } from "./minimax"
import { JuneTotals } from "./june"
function Root() {
return (
<>
<Composition id="GLM52Rise" component={GLM52Rise} durationInFrames={240} fps={30} width={1080} height={1080} />
<Composition id="NZSheep" component={NZSheep} durationInFrames={150} fps={30} width={1080} height={1080} />
<Composition
id="NovelTokens"
component={NovelTokens}
durationInFrames={150}
fps={30}
width={1080}
height={1080}
/>
<Composition id="FlashShare" component={FlashShare} durationInFrames={165} fps={30} width={1080} height={1080} />
<Composition
id="MiniMaxClimb"
component={MiniMaxClimb}
durationInFrames={165}
fps={30}
width={1080}
height={1080}
/>
<Composition id="JuneTotals" component={JuneTotals} durationInFrames={1} fps={30} width={1080} height={1080} />
</>
)
}
registerRoot(Root)
-144
View File
@@ -1,144 +0,0 @@
import React from "react"
import { AbsoluteFill } from "remotion"
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#dcdcdc",
accent: "#3b5cf6",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
// verified: OpenCode Go (tier=Go, dataset=zen), June 1-30, 2026.
// 72.78T tokens · 651.4M requests · 11.42M sessions -> rounded headline figures.
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
function DotBand() {
return (
<div
style={{
height: 12,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
WebkitMaskPosition: "left top",
maskPosition: "left top",
}}
/>
)
}
function Metric({ value, label }: { value: string; label: string }) {
return (
<div>
<div
style={{
fontSize: 92,
fontWeight: 600,
letterSpacing: -3,
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
marginLeft: -8, // align the glyph's visual left edge to the column
}}
>
{value}
</div>
<div style={{ marginTop: 16, fontSize: 24, fontWeight: 500, color: c.muted, letterSpacing: 1 }}>{label}</div>
</div>
)
}
export function JuneTotals() {
return (
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 22 }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>MONTHLY RECAP</div>
</div>
<DotBand />
{/* hero */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<div style={{ fontSize: 25, fontWeight: 600, color: c.muted, letterSpacing: 3 }}>OPENCODE GO · JUNE 2026</div>
<div style={{ marginTop: 14 }}>
<div
style={{
fontSize: 300,
fontWeight: 600,
letterSpacing: -10,
lineHeight: 0.82,
color: c.accent,
marginLeft: -22,
}}
>
73T
</div>
<div style={{ fontSize: 50, fontWeight: 600, color: c.ink, marginTop: 10 }}>tokens processed</div>
</div>
{/* supporting metrics */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
marginTop: 60,
paddingTop: 40,
borderTop: `1px solid ${c.line}`,
}}
>
<Metric value="650M" label="requests" />
<Metric value="11M" label="sessions" />
</div>
</div>
{/* footer */}
<DotBand />
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
marginTop: 22,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</AbsoluteFill>
)
}
-201
View File
@@ -1,201 +0,0 @@
import React from "react"
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion"
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#e4e4e4",
accent: "#3b5cf6",
accentHi: "#5b78ff",
accentDim: "#aebcf3",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
// verified: minimax-m3, OpenCode Go (tier=Go, dataset=zen), weekly total_tokens.
// W26 2.559T is +23.2% / +482.3B vs W25 2.077T.
const weeks = [
{ label: "May 25", t: 0.008 },
{ label: "Jun 1", t: 0.429 },
{ label: "Jun 8", t: 1.192 },
{ label: "Jun 15", t: 2.077 },
{ label: "Jun 22", t: 2.559, latest: true },
]
const AXIS_MAX = 3.0
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.ink }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
const CHART_H = 420 // multiple of 12 so the dotted tracks never clip
export function MiniMaxClimb() {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const grow = (i: number) =>
Math.min(
1,
Math.max(0, spring({ frame: frame - 22 - i * 9, fps, config: { damping: 18, stiffness: 110, mass: 0.6 } })),
)
return (
<AbsoluteFill style={{ background: c.bg, color: c.ink, fontFamily: MONO, padding: 72, boxSizing: "border-box" }}>
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 2228, 2026</div>
</div>
{/* headline (static) */}
<div style={{ marginTop: 50 }}>
<div style={{ fontSize: 23, fontWeight: 600, color: c.muted, letterSpacing: 2 }}>
OPENCODE GO · WEEKLY TOKENS
</div>
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 24, marginTop: 14 }}
>
<div style={{ fontSize: 62, fontWeight: 600, letterSpacing: -2, lineHeight: 1 }}>MiniMax M3</div>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div
style={{
fontSize: 84,
fontWeight: 600,
letterSpacing: -2,
lineHeight: 1,
color: c.accent,
fontVariantNumeric: "tabular-nums",
}}
>
+23.2%
</div>
<div style={{ fontSize: 19, fontWeight: 500, color: c.muted, letterSpacing: 1, marginTop: 6 }}>
WEEK OVER WEEK
</div>
</div>
</div>
</div>
{/* column chart */}
<div style={{ flex: 1, display: "flex", alignItems: "flex-end", marginTop: 30 }}>
<div style={{ width: "100%", display: "flex", alignItems: "flex-end", gap: 30 }}>
{weeks.map((w, i) => {
const g = grow(i)
const h = Math.round((w.t / AXIS_MAX) * CHART_H * g)
return (
<div key={w.label} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
{/* value + bar */}
<div style={{ position: "relative", width: "100%", height: CHART_H }}>
{/* dotted track */}
<div
style={{
position: "absolute",
inset: 0,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
WebkitMaskPosition: "left bottom",
maskPosition: "left bottom",
}}
/>
{/* fill */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: h,
background: w.latest ? c.accent : c.accentDim,
borderTop: w.latest ? `3px solid ${c.accentHi}` : "none",
}}
/>
{/* value label */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: h + 10,
textAlign: "center",
fontSize: 26,
fontWeight: w.latest ? 600 : 500,
color: w.latest ? c.accent : c.muted,
fontVariantNumeric: "tabular-nums",
opacity: interpolate(g, [0.5, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
}}
>
{w.t.toFixed(2)}T
</div>
</div>
{/* week label */}
<div
style={{
marginTop: 14,
fontSize: 18,
fontWeight: 500,
color: w.latest ? c.ink : c.faint,
}}
>
{w.label}
</div>
</div>
)
})}
</div>
</div>
{/* footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 22,
paddingTop: 22,
borderTop: `1px solid ${c.line}`,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
2.56T tokens last week · +482.3B added
</div>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</AbsoluteFill>
)
}
-135
View File
@@ -1,135 +0,0 @@
import React from "react"
import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion"
const c = {
white: "#ffffff",
dim: "rgba(255,255,255,0.74)",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26)
// 19,642,742,937,105 tokens / 173,651,197 requests = 113,116 tokens/request
const AVG = 113116
const K = Math.round(AVG / 1000) // 113
const nf = new Intl.NumberFormat("en-US")
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.white }}>
<path opacity="0.35" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function NovelTokens() {
const frame = useCurrentFrame()
const k = Math.round(
K *
interpolate(frame, [18, 92], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
}),
)
const zoom = interpolate(frame, [0, 150], [1.06, 1.12], {
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
})
return (
<AbsoluteFill style={{ background: "#0c0c0c", overflow: "hidden" }}>
<Img
src={staticFile("book.jpg")}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center 34%",
transform: `scale(${zoom})`,
transformOrigin: "center 30%",
}}
/>
{/* legibility scrims */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 20%, rgba(0,0,0,0.1) 44%, rgba(0,0,0,0.86) 100%)",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
boxSizing: "border-box",
padding: 64,
color: c.white,
fontFamily: MONO,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.dim, letterSpacing: 1 }}>JUN 2228, 2026</div>
</div>
{/* bottom block */}
<div>
<div style={{ fontSize: 23, fontWeight: 600, color: c.dim, letterSpacing: 2, marginBottom: 8 }}>
OPENCODE GO · LAST WEEK
</div>
<div
style={{
fontSize: 168,
fontWeight: 600,
lineHeight: 0.92,
letterSpacing: -5,
fontVariantNumeric: "tabular-nums",
}}
>
{k}K
</div>
<div style={{ fontSize: 50, fontWeight: 600, letterSpacing: -1, marginTop: 4 }}>tokens per request</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 30,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ color: c.dim }}>{nf.format(AVG)} tokens / request · last week</div>
<div style={{ color: c.white }}>opencode.ai/data</div>
</div>
</div>
</div>
</AbsoluteFill>
)
}
-139
View File
@@ -1,139 +0,0 @@
import React from "react"
import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion"
const c = {
white: "#ffffff",
dim: "rgba(255,255,255,0.72)",
faint: "rgba(255,255,255,0.55)",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
// verified: NZ OpenCode Go, week of Jun 22-28, 2026 (2026-W26)
const TOKENS = 40_915_594_381 // 40.9B
const SHEEP = 23_600_000 // 23.6M
const PER_SHEEP = Math.round(TOKENS / SHEEP) // 1,734
const nf = new Intl.NumberFormat("en-US")
// the correct opencode "DATA" wordmark (white, over photo)
function DataWordmark({ height = 30 }: { height?: number }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color: c.white }}>
<path opacity="0.35" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function NZSheep() {
const frame = useCurrentFrame()
const count = Math.round(
PER_SHEEP *
interpolate(frame, [18, 90], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
}),
)
// slow Ken Burns push-in (scale up only — never reveals an edge)
const zoom = interpolate(frame, [0, 150], [1.06, 1.12], {
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
})
return (
<AbsoluteFill style={{ background: "#0c0c0c", overflow: "hidden" }}>
{/* the sheep, staring */}
<Img
src={staticFile("sheep.jpg")}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center 38%",
transform: `scale(${zoom})`,
transformOrigin: "center 35%",
}}
/>
{/* legibility scrims */}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,0) 48%, rgba(0,0,0,0.78) 100%)",
}}
/>
{/* content */}
<div
style={{
position: "absolute",
inset: 0,
boxSizing: "border-box",
padding: 64,
color: c.white,
fontFamily: MONO,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.dim, letterSpacing: 1 }}>JUN 2228, 2026</div>
</div>
{/* bottom block */}
<div>
<div style={{ fontSize: 23, fontWeight: 600, color: c.dim, letterSpacing: 2, marginBottom: 8 }}>
OPENCODE GO · NEW ZEALAND
</div>
<div
style={{
fontSize: 168,
fontWeight: 600,
lineHeight: 0.92,
letterSpacing: -5,
fontVariantNumeric: "tabular-nums",
}}
>
{nf.format(count)}
</div>
<div style={{ fontSize: 50, fontWeight: 600, letterSpacing: -1, marginTop: 4 }}>tokens per sheep</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 34,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ color: c.dim }}>40.9B tokens ÷ 23.6M sheep · last week</div>
<div style={{ color: c.white }}>opencode.ai/data</div>
</div>
</div>
</div>
</AbsoluteFill>
)
}
-254
View File
@@ -1,254 +0,0 @@
import React from "react"
import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"
import { days, launchIndex, glmWeekTokensT, segments } from "./data"
// stats.opencode.ai design tokens (light theme)
const c = {
bg: "#ffffff",
ink: "#161616",
muted: "#5c5c5c",
faint: "#808080",
line: "#e6e6e6",
dot: "#ededed",
accent: "#3b5cf6",
accentHi: "#5b78ff",
}
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
const W = 1080
const DOT_MASK =
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")"
const field = segments.filter((s) => !s.hero)
const glmColor = segments.find((s) => s.hero)!.color
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
// the correct opencode "DATA" wordmark (from stats.opencode.ai header)
function DataWordmark({ height = 30, color = c.ink }: { height?: number; color?: string }) {
return (
<svg width={(height * 66) / 20} height={height} viewBox="0 0 66 20" fill="none" style={{ color }}>
<path opacity="0.2" d="M12 16H4V8H12V16Z" fill="currentColor" />
<path d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="currentColor" />
<path
d="M63.3543 16L62.5119 12.8711H58.6437L57.8013 16H55.7383L59.2454 4H61.9618L65.4689 16H63.3543ZM61.0678 7.851L60.6896 5.94269H60.4489L60.0707 7.851L59.1595 11.1347H61.9962L61.0678 7.851Z"
fill="currentColor"
/>
<path d="M52.5951 5.87392V16H50.4461V5.87392H47.4375V4H55.6209V5.87392H52.5951Z" fill="currentColor" />
<path
d="M45.2059 16L44.3635 12.8711H40.4953L39.6529 16H37.5898L41.097 4H43.8133L47.3205 16H45.2059ZM42.9194 7.851L42.5411 5.94269H42.3004L41.9222 7.851L41.011 11.1347H43.8477L42.9194 7.851Z"
fill="currentColor"
/>
<path
d="M28 4H32.0917C32.8138 4 33.4556 4.11461 34.0172 4.34384C34.5903 4.5616 35.0716 4.9169 35.4613 5.40974C35.8625 5.89112 36.1662 6.51003 36.3725 7.26648C36.5788 8.02292 36.6819 8.9341 36.6819 10C36.6819 11.0659 36.5788 11.9771 36.3725 12.7335C36.1662 13.49 35.8625 14.1146 35.4613 14.6075C35.0716 15.0888 34.5903 15.4441 34.0172 15.6734C33.4556 15.8911 32.8138 16 32.0917 16H28V4ZM32.0917 14.1261C32.8252 14.1261 33.3926 13.9026 33.7937 13.4556C34.1948 12.9971 34.3954 12.3152 34.3954 11.4097V8.59026C34.3954 7.68481 34.1948 7.0086 33.7937 6.5616C33.3926 6.10315 32.8252 5.87392 32.0917 5.87392H30.149V14.1261H32.0917Z"
fill="currentColor"
/>
</svg>
)
}
export function GLM52Rise() {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
// ---------- virtual camera ----------
// open zoomed-in on the field, pan right while the blue fills, then pull back to reveal.
const K = [0, 32, 150, 206, 240]
const ease = Easing.inOut(Easing.cubic)
const opt = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const, easing: ease }
const s = interpolate(frame, K, [1.82, 1.72, 1.72, 1.0, 1.0], opt)
let fx = interpolate(frame, K, [420, 438, 760, 540, 540], opt)
let fy = interpolate(frame, K, [664, 664, 664, 540, 540], opt)
// keep the framing inside the 1080 canvas so edges never reveal black
fx = clamp(fx, 540 / s, W - 540 / s)
fy = clamp(fy, 540 / s, W - 540 / s)
const camera = `translate(${540 - fx * s}px, ${540 - fy * s}px) scale(${s})`
// ---------- blue sweep (synced to the pan) ----------
const p = interpolate(frame, [32, 150], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease })
const revealAmount = p * (days.length - launchIndex) + 0.35
const fillOf = (i: number) => clamp(revealAmount - (i - launchIndex), 0, 1)
// token number climbs as the camera pulls back and the headline re-enters frame
const tokensT =
glmWeekTokensT *
interpolate(frame, [150, 202], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
})
// chart geometry
const chartH = 440
const chartW = 936
const gap = 14
const EXAGGERATE = 2.876 // broken y-axis: GLM-5.2 magnified ~2.9x for emphasis
return (
<AbsoluteFill style={{ background: c.bg, overflow: "hidden" }}>
<div
style={{
position: "absolute",
width: W,
height: W,
background: c.bg,
transformOrigin: "0 0",
transform: camera,
}}
>
<div
style={{
width: W,
height: W,
boxSizing: "border-box",
padding: 72,
color: c.ink,
fontFamily: MONO,
display: "flex",
flexDirection: "column",
}}
>
{/* header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<DataWordmark height={30} />
<div style={{ fontSize: 20, fontWeight: 500, color: c.faint, letterSpacing: 1 }}>JUN 1225, 2026</div>
</div>
{/* headline (static) */}
<div style={{ marginTop: 52 }}>
<div style={{ fontSize: 92, fontWeight: 600, lineHeight: 0.98, letterSpacing: -2 }}>
GLM-5.2 <span style={{ color: c.accent }}>broke out</span>
</div>
<div style={{ marginTop: 22, fontSize: 30, fontWeight: 500, color: c.muted }}>
From 0 to <span style={{ color: c.ink }}>{tokensT.toFixed(2)}T tokens</span> in a week.
</div>
</div>
{/* stacked chart */}
<div style={{ flex: 1, display: "flex", alignItems: "flex-end", marginTop: 40 }}>
<div
style={{
position: "relative",
width: chartW,
height: chartH,
margin: "0 auto",
borderBottom: `2px solid ${c.ink}`,
boxSizing: "border-box",
}}
>
{/* faint dotted backdrop */}
<div
style={{
position: "absolute",
left: -8,
right: -8,
top: -8,
bottom: 0,
background: c.dot,
WebkitMaskImage: DOT_MASK,
maskImage: DOT_MASK,
WebkitMaskSize: "12px 12px",
maskSize: "12px 12px",
WebkitMaskRepeat: "repeat",
maskRepeat: "repeat",
}}
/>
{/* columns */}
<div style={{ position: "absolute", inset: 0, display: "flex", gap, alignItems: "flex-end" }}>
{days.map((d, i) => {
const glmShare = d.glm / d.total
const blueH = Math.round(glmShare * EXAGGERATE * chartH)
const filled = Math.round(blueH * fillOf(i))
const slotGap = filled > 2 ? 5 : 0
const fieldH = chartH - filled - slotGap
const fieldTotal = d.dsf + d.dsp + d.mm + d.others
return (
<div key={i} style={{ position: "relative", flex: 1, height: chartH }}>
{/* gray field of other models (already in place) */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: fieldH,
display: "flex",
flexDirection: "column-reverse",
}}
>
{field.map((seg) => (
<div
key={seg.key}
style={{
height: Math.round(((d[seg.key as keyof typeof d] as number) / fieldTotal) * fieldH),
background: seg.color,
borderTop: `2px solid ${c.bg}`,
}}
/>
))}
</div>
{/* GLM-5.2, animates in */}
{filled > 2 && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: filled,
background: glmColor,
borderTop: `2px solid ${c.accentHi}`,
}}
/>
)}
</div>
)
})}
</div>
</div>
</div>
{/* day axis */}
<div style={{ display: "flex", gap, width: chartW, margin: "14px auto 0" }}>
{days.map((d, i) => (
<div
key={i}
style={{
flex: 1,
textAlign: "center",
fontSize: 15,
fontWeight: 500,
color: i === days.length - 1 ? c.ink : c.faint,
}}
>
{i === 0 || i === launchIndex || i === days.length - 1 ? d.date : ""}
</div>
))}
</div>
{/* footer */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: 28,
paddingTop: 22,
borderTop: `1px solid ${c.line}`,
fontSize: 20,
fontWeight: 500,
}}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: 9, color: c.muted }}>
<span style={{ width: 13, height: 13, background: c.accent, display: "inline-block" }} />
GLM-5.2
</div>
<div style={{ color: c.ink }}>opencode.ai/data</div>
</div>
</div>
</div>
</AbsoluteFill>
)
}
+40 -29
View File
@@ -19,12 +19,13 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"glob": "13.0.5", "glob": "13.0.5",
"husky": "9.1.7", "husky": "9.1.7",
"node-gyp": "12.4.0",
"oxlint": "1.60.0", "oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0", "oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "catalog:",
"turbo": "2.10.2", "turbo": "2.8.13",
}, },
}, },
"packages/app": { "packages/app": {
@@ -2791,18 +2792,6 @@
"@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-wBM3ObqOWnKUDmg7QfUFDkDHPFUAJmrYlYqmEM8jMPAPA/I6wRJIbWimeQUqhOiQ8xPKhzyWM+xaiUP0wz8FEQ=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/Cq0joWnuMjDPfhjbFP4sv+C/7gkQ415zlaO4XUzD5EZxbtrKgXKvuuydMvogG8GeUnN1aDltW71RlmEfpjbyw=="],
"@turbo/linux-64": ["@turbo/linux-64@2.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mMsf5IIhiKuceEXNstd25IbadjBXZ0amxzFOqliEzJX6HyeeHdBQPVSY583PWqYDyqM/FB8d5ZjkthfBSeuH3Q=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Wcng1i2kaKmXutmwxT9MUoYZvdaIekXAdlGr4+0TpgbhGLw7nDuEcRBFrxb5BbRoX1d1q8SpdRxLc45TvDZIdQ=="],
"@turbo/windows-64": ["@turbo/windows-64@2.10.2", "", { "os": "win32", "cpu": "x64" }, "sha512-SsNhM7Ho7EpAdwtrJKBOic9Hso23vu6Dp0gAfLOvUFjPzurr/sGQlXZEvr6z89ne4RDOypTwz5CBDrixpMKtXw=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gf+S7ICAdimT/n02bOuVWKvhHnct/HYjZg3oBNIz5hZ9ZyWHbQim9J3P5Qip8WpX0ksxF7eaBVziJCuLnjhqDg=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
@@ -3611,7 +3600,7 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
@@ -4533,7 +4522,7 @@
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
@@ -5333,7 +5322,19 @@
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
"turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="], "turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="],
"turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="],
"turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="],
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
@@ -5371,7 +5372,7 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="], "undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -5573,7 +5574,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -5621,8 +5622,6 @@
"@actions/github/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@actions/github/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@actions/http-client/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
@@ -5875,6 +5874,8 @@
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@effect/platform-node/undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -5885,12 +5886,16 @@
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="], "@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
"@electron/rebuild/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
"@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], "@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -5941,6 +5946,8 @@
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"@npmcli/run-script/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="], "@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="],
"@octokit/auth-app/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], "@octokit/auth-app/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
@@ -6215,6 +6222,8 @@
"conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], "conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
@@ -6353,9 +6362,7 @@
"nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="], "nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "node-gyp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -6459,8 +6466,6 @@
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
@@ -6947,8 +6952,6 @@
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -7079,6 +7082,10 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], "motion/framer-motion/motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
@@ -7091,6 +7098,8 @@
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -7359,6 +7368,8 @@
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@@ -7381,8 +7392,6 @@
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -7491,6 +7500,8 @@
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=", "x86_64-linux": "sha256-ZmAIcG3wPgssKejLe/EMJOjPuMHCyRiiItRvSqIjaME=",
"aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=", "aarch64-linux": "sha256-lbAHoVI/CvgdrfKZ/9dKZ/O7DxJvuWEOrDgcCuFGPBk=",
"aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=", "aarch64-darwin": "sha256-Nrs4ijvzQ3J8AYigJSlsr66feUvLZ5iiYCfBdaZNJEc=",
"x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0=" "x86_64-darwin": "sha256-ltCaJSO7kZWuMOU49269xvboNVX35OJDFj0gXCDf62Y="
} }
} }
+2 -1
View File
@@ -99,12 +99,13 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"glob": "13.0.5", "glob": "13.0.5",
"husky": "9.1.7", "husky": "9.1.7",
"node-gyp": "12.4.0",
"oxlint": "1.60.0", "oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0", "oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "catalog:", "sst": "catalog:",
"turbo": "2.10.2" "turbo": "2.8.13"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
@@ -1,19 +0,0 @@
import { describe, expect, test } from "bun:test"
import { matchesModelSearch } from "./dialog-select-model-search"
describe("matchesModelSearch", () => {
test("matches model names across separators", () => {
expect(matchesModelSearch("gpt 5", ["GPT-5.5"])).toBe(true)
expect(matchesModelSearch("gpt-5", ["GPT-5.5"])).toBe(true)
expect(matchesModelSearch("gpt5", ["GPT-5.5"])).toBe(true)
})
test("matches any searchable model field", () => {
expect(matchesModelSearch("open ai", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
expect(matchesModelSearch("gpt 5", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true)
})
test("does not match unrelated searches", () => {
expect(matchesModelSearch("claude", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(false)
})
})
@@ -1,18 +0,0 @@
export const normalizeModelSearch = (value: string) =>
value
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
.trim()
.replace(/\s+/g, " ")
export const compactModelSearch = (value: string) => normalizeModelSearch(value).replaceAll(" ", "")
export const matchesModelSearch = (query: string, values: string[]) => {
const search = normalizeModelSearch(query)
if (!search) return true
const compactSearch = compactModelSearch(query)
return values.some(
(value) => normalizeModelSearch(value).includes(search) || compactModelSearch(value).includes(compactSearch),
)
}
@@ -1,15 +1,5 @@
import { Popover as Kobalte } from "@kobalte/core/popover" import { Popover as Kobalte } from "@kobalte/core/popover"
import { import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
Component,
ComponentProps,
createEffect,
createMemo,
For,
JSX,
onCleanup,
Show,
ValidComponent,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -27,9 +17,6 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search"
const isFree = (provider: string, cost: { input: number } | undefined) => const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0) provider === "opencode" && (!cost || cost.input === 0)
@@ -255,9 +242,14 @@ export function ModelSelectorPopoverV2(props: {
.filter((item) => (props.provider ? item.provider.id === props.provider : true)), .filter((item) => (props.provider ? item.provider.id === props.provider : true)),
) )
const models = createMemo(() => { const models = createMemo(() => {
const search = store.search.trim() const search = store.search.trim().toLowerCase()
const filtered = search const filtered = search
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name])) ? allModels().filter(
(item) =>
item.name.toLowerCase().includes(search) ||
item.id.toLowerCase().includes(search) ||
item.provider.name.toLowerCase().includes(search),
)
: allModels() : allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
@@ -341,23 +333,19 @@ export function ModelSelectorPopoverV2(props: {
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
} }
const setSearch = (value: string) => { const setSearch = (value: string) => {
const search = value.trim() const search = value.trim().toLowerCase()
const first = [...allModels()] const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name])) .find(
(item) =>
!search ||
item.name.toLowerCase().includes(search) ||
item.id.toLowerCase().includes(search) ||
item.provider.name.toLowerCase().includes(search),
)
setStore({ search: value, active: first ? modelKey(first) : manageKey }) setStore({ search: value, active: first ? modelKey(first) : manageKey })
} }
createEffect(() => {
if (!store.open) return
createEventListener(
document,
"keydown",
(event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch),
true,
)
})
return ( return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}> <MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}> <MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
+1 -3
View File
@@ -218,7 +218,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
let restoreEndOnFocus = true let restoreEndOnFocus = true
let savedCursor: number | null = null
const mirror = { input: false } const mirror = { input: false }
const inset = 56 const inset = 56
@@ -591,7 +590,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const restoreFocus = () => { const restoreFocus = () => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current()) const cursor = prompt.cursor() ?? promptLength(prompt.current())
editorRef.focus() editorRef.focus()
setCursorPosition(editorRef, cursor) setCursorPosition(editorRef, cursor)
queueScroll() queueScroll()
@@ -628,7 +627,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => { const handleBlur = () => {
savedCursor = currentCursor()
closePopover() closePopover()
setComposing(false) setComposing(false)
} }
@@ -1,4 +1,4 @@
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js" import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -8,7 +8,6 @@ import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
export type PromptProject = { export type PromptProject = {
name?: string name?: string
@@ -102,16 +101,6 @@ export function createPromptProjectController(input: {
setStore({ open: false, search: "", active: "" }) setStore({ open: false, search: "", active: "" })
input.controls().add(language.t("command.project.open"), server) input.controls().add(language.t("command.project.open"), server)
} }
const setSearch = (value: string) => {
const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({
search: value,
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
})
}
return { return {
selected, selected,
@@ -138,7 +127,16 @@ export function createPromptProjectController(input: {
} }
setStore({ open: false, search: "", active: "" }) setStore({ open: false, search: "", active: "" })
}, },
setSearch, setSearch(value: string) {
const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({
search: value,
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
})
},
clearSearch() { clearSearch() {
setStore({ search: "", active: initialActive() }) setStore({ search: "", active: initialActive() })
setTimeout(() => searchRef?.focus()) setTimeout(() => searchRef?.focus())
@@ -172,9 +170,6 @@ export function createPromptProjectController(input: {
focusSearch() { focusSearch() {
setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
}, },
handleSearchKeydown(event: KeyboardEvent) {
return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch)
},
} }
} }
@@ -248,13 +243,6 @@ export function PromptProjectSelector(props: {
return project ? props.controller.projectKey(project) : undefined return project ? props.controller.projectKey(project) : undefined
} }
createEffect(() => {
if (!props.controller.open()) return
const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event)
document.addEventListener("keydown", handler, true)
onCleanup(() => document.removeEventListener("keydown", handler, true))
})
return ( return (
<DropdownMenu <DropdownMenu
open={props.controller.open()} open={props.controller.open()}
@@ -399,7 +399,7 @@
overflow: hidden; overflow: hidden;
font-size: 13px; font-size: 13px;
font-weight: 440; font-weight: 440;
line-height: 16px; line-height: 1;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -3,7 +3,7 @@ import { createSignal, Show, type JSXElement } from "solid-js"
import "./titlebar-tab-popover.css" import "./titlebar-tab-popover.css"
// Initial hover delay before the preview appears, per design. // Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 400 const OPEN_DELAY = 200
// Mouse-out delay: begin closing immediately (a brief exit animation plays). // Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0 const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips // After a preview closes, hovering a neighbouring tab within this window skips
+1 -11
View File
@@ -1,8 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createEffect, createMemo, createRoot } from "solid-js" import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { createServerProjects, ServerConnection, useServer } from "./server"
import { pathKey } from "@/utils/path-key"
import { useServerHealth } from "@/utils/server-health" import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk" import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync" import { createServerSyncContext } from "./server-sync"
@@ -128,14 +127,6 @@ function createServerCtx(
} }
const projectsList = createMemo(() => projects.list().map(enrich)) const projectsList = createMemo(() => projects.list().map(enrich))
const recentlyClosedList = createMemo(() => {
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
return projects
.recentlyClosed()
.filter((worktree) => known.has(pathKey(worktree)))
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
.map((worktree) => enrich({ worktree, expanded: false }))
})
const isLocal = const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
@@ -148,7 +139,6 @@ function createServerCtx(
projects: { projects: {
...projects, ...projects,
list: projectsList, list: projectsList,
recentlyClosed: recentlyClosedList,
}, },
} }
} }
+2 -11
View File
@@ -5,11 +5,10 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2" import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@@ -494,7 +493,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const root = rootFor(project.worktree) const root = rootFor(project.worktree)
if (root === project.worktree) continue if (root === project.worktree) continue
server.projects.remove(project.worktree) server.projects.close(project.worktree)
if (!seen.has(root)) { if (!seen.has(root)) {
server.projects.open(root) server.projects.open(root)
@@ -614,14 +613,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
projects: { projects: {
list, list,
recentlyClosed: createMemo(() => {
const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
return server.projects
.recentlyClosed()
.filter((worktree) => known.has(pathKey(worktree)))
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
.map((worktree) => enrich({ worktree, expanded: false }))
}),
open(directory: string) { open(directory: string) {
const root = rootFor(directory) const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return if (server.projects.list().find((x) => x.worktree === root)) return
+1 -83
View File
@@ -99,7 +99,7 @@ describe("createServerProjects", () => {
test("keeps active and explicit server buckets in one reactive store", () => { test("keeps active and explicit server buckets in one reactive store", () => {
createRoot((dispose) => { createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local) const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) const [store, setStore] = createStore({ projects: {}, lastProject: {} })
const active = createServerProjects({ scope, store, setStore }) const active = createServerProjects({ scope, store, setStore })
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore }) const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
@@ -115,88 +115,6 @@ describe("createServerProjects", () => {
dispose() dispose()
}) })
}) })
test("tracks recently closed projects and drops them when reopened", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.open("/a")
projects.open("/b")
projects.close("/a")
expect(projects.recentlyClosed()).toEqual(["/a"])
projects.close("/b")
expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
projects.open("/a")
expect(projects.recentlyClosed()).toEqual(["/b"])
expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
dispose()
})
})
test("remove drops a project without recording it as recently closed", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.open("/repo/subdir")
projects.remove("/repo/subdir")
expect(projects.list()).toEqual([])
expect(projects.recentlyClosed()).toEqual([])
dispose()
})
})
test("retains recently closed history beyond the visible display limit", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
// Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
// this prevents display-filtered entries from evicting still-visible ones.
for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
projects.open(dir)
projects.close(dir)
}
expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
dispose()
})
})
test("caps recently closed history at the store limit", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
for (let i = 1; i <= 20; i++) {
projects.open(`/p${i}`)
projects.close(`/p${i}`)
}
expect(projects.recentlyClosed()).toHaveLength(16)
expect(projects.recentlyClosed()[0]).toBe("/p20")
expect(projects.recentlyClosed().at(-1)).toBe("/p5")
dispose()
})
})
test("dedupes recently closed entries by normalized path", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.close("/repo")
projects.close("/repo/")
expect(projects.recentlyClosed()).toEqual(["/repo/"])
dispose()
})
})
}) })
describe("migrateCanonicalLocalServerState", () => { describe("migrateCanonicalLocalServerState", () => {
+5 -40
View File
@@ -2,23 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js" import { type Accessor, batch, createMemo } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
type StoredProject = { worktree: string; expanded: boolean } type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = { type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
projects: Record<string, StoredProject[]>
lastProject: Record<string, string>
recentlyClosed: Record<string, string[]>
}
const HEALTH_POLL_INTERVAL_MS = 10_000 const HEALTH_POLL_INTERVAL_MS = 10_000
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
// filtered out do not evict still-visible ones from the persisted store.
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) { export function normalizeServerUrl(input: string) {
const trimmed = input.trim() const trimmed = input.trim()
@@ -83,42 +72,19 @@ export function createServerProjects<T extends ServerProjectState>(input: {
}) { }) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState> const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const current = () => input.store.projects[input.scope()] ?? [] const current = () => input.store.projects[input.scope()] ?? []
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
const remove = (directory: string) => {
setStore(
"projects",
input.scope(),
current().filter((project) => project.worktree !== directory),
)
}
return { return {
list: current, list: current,
recentlyClosed: currentClosed,
remove,
open(directory: string) { open(directory: string) {
const scope = input.scope() const scope = input.scope()
const key = pathKey(directory)
const closed = currentClosed()
if (closed.some((worktree) => pathKey(worktree) === key)) {
setStore(
"recentlyClosed",
scope,
closed.filter((worktree) => pathKey(worktree) !== key),
)
}
if (current().some((project) => project.worktree === directory)) return if (current().some((project) => project.worktree === directory)) return
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()]) setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
}, },
// User-initiated close: removes the project and records it in recently closed.
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
close(directory: string) { close(directory: string) {
remove(directory) setStore(
const key = pathKey(directory) "projects",
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice( input.scope(),
0, current().filter((project) => project.worktree !== directory),
RECENTLY_CLOSED_HISTORY_LIMIT,
) )
setStore("recentlyClosed", input.scope(), closed)
}, },
expand(directory: string) { expand(directory: string) {
const index = current().findIndex((project) => project.worktree === directory) const index = current().findIndex((project) => project.worktree === directory)
@@ -269,7 +235,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
list: [] as StoredServer[], list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>, projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>, lastProject: {} as Record<string, string>,
recentlyClosed: {} as Record<string, string[]>,
}), }),
) )
-1
View File
@@ -604,7 +604,6 @@ export const dict = {
"home.title": "Home", "home.title": "Home",
"home.projects": "Projects", "home.projects": "Projects",
"home.project.add": "Add project", "home.project.add": "Add project",
"home.recentlyClosed": "Recently closed",
"home.server.collapse": "Collapse server projects", "home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects", "home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions", "home.sessions.search.placeholder": "Search sessions",
+6 -105
View File
@@ -270,10 +270,6 @@ export function NewHome() {
}) })
const focusedSync = () => focusedServerCtx()?.sync ?? sync() const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
)
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory)) const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo( const newSessionProject = createMemo(
() => () =>
@@ -522,13 +518,10 @@ export function NewHome() {
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6"> <div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn <HomeProjectColumn
projects={projects()} projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()} selected={selection()}
focusServer={focusServer} focusServer={focusServer}
selectProject={selectProject} selectProject={selectProject}
openNewSession={openProjectNewSession} openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)} chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject} editProject={editProject}
closeProject={(conn, directory) => { closeProject={(conn, directory) => {
@@ -645,13 +638,10 @@ export function NewHome() {
function HomeProjectColumn(props: { function HomeProjectColumn(props: {
projects: LocalProject[] projects: LocalProject[]
recentlyClosed: LocalProject[]
homedir: string
selected: HomeProjectSelection selected: HomeProjectSelection
focusServer: (server: ServerConnection.Any) => void focusServer: (server: ServerConnection.Any) => void
selectProject: (server: ServerConnection.Any, directory: string) => void selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void openNewSession: (server: ServerConnection.Any, directory: string) => void
openRecentProject: (server: ServerConnection.Any, directory: string) => void
chooseProject: (server: ServerConnection.Any) => void chooseProject: (server: ServerConnection.Any) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void closeProject: (server: ServerConnection.Any, directory: string) => void
@@ -680,10 +670,8 @@ function HomeProjectColumn(props: {
aria-label={props.language.t("home.projects")} aria-label={props.language.t("home.projects")}
> >
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3"> <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div> <div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show <Show when={global.servers.list().length === 1}>
when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}> <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2 <IconButtonV2
data-action="home-add-project" data-action="home-add-project"
@@ -703,21 +691,7 @@ function HomeProjectColumn(props: {
when={global.servers.list().length > 1} when={global.servers.list().length > 1}
fallback={ fallback={
<div class="pr-3"> <div class="pr-3">
<Show <HomeProjectList {...props} server={global.servers.list()[0]!} />
when={props.projects.length > 0}
fallback={
<HomeProjectEmpty
server={global.servers.list()[0]!}
recentlyClosed={props.recentlyClosed}
homedir={props.homedir}
chooseProject={props.chooseProject}
openRecentProject={props.openRecentProject}
language={props.language}
/>
}
>
<HomeProjectList {...props} server={global.servers.list()[0]!} />
</Show>
</div> </div>
} }
> >
@@ -923,79 +897,6 @@ function HomeProjectList(props: {
) )
} }
function HomeProjectEmpty(props: {
server: ServerConnection.Any
recentlyClosed: LocalProject[]
homedir: string
chooseProject: (server: ServerConnection.Any) => void
openRecentProject: (server: ServerConnection.Any, directory: string) => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
return (
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
data-action="home-add-project-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
disabled={unreachable()}
onClick={() => props.chooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</button>
<Show when={props.recentlyClosed.length > 0}>
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
</div>
<For each={props.recentlyClosed}>
{(project) => (
<HomeRecentlyClosedRow
project={project}
server={props.server}
homedir={props.homedir}
openRecentProject={props.openRecentProject}
language={props.language}
/>
)}
</For>
</Show>
</div>
)
}
function HomeRecentlyClosedRow(props: {
project: LocalProject
server: ServerConnection.Any
homedir: string
openRecentProject: (server: ServerConnection.Any, directory: string) => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const path = () => {
const home = props.homedir
const worktree = props.project.worktree
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
return worktree
}
return (
<TooltipV2 placement="right" value={path()}>
<button
type="button"
data-component="home-recently-closed-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={unreachable()}
onClick={() => props.openRecentProject(props.server, props.project.worktree)}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
</TooltipV2>
)
}
function HomeProjectRow(props: { function HomeProjectRow(props: {
project: LocalProject project: LocalProject
server: ServerConnection.Any server: ServerConnection.Any
@@ -1078,13 +979,13 @@ function HomeProjectRow(props: {
) )
} }
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) { function HomeProjectAvatar(props: { project: LocalProject }) {
const name = createMemo(() => displayName(props.project)) const name = createMemo(() => displayName(props.project))
return ( return (
<ProjectAvatar <ProjectAvatar
fallback={name()} fallback={name()}
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)} src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)} variant={getProjectAvatarVariant(props.project.icon?.color)}
/> />
) )
} }
@@ -1393,14 +1393,14 @@ export function MessageTimeline(props: {
<button <button
type="button" type="button"
data-slot="session-title-parent" data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted" class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent} onClick={navigateParent}
> >
{parentTitle()} {parentTitle()}
</button> </button>
<span <span
data-slot="session-title-separator" data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint" class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true" aria-hidden="true"
> >
/ /
@@ -3,7 +3,6 @@ import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout" import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership" import { createSessionOwnership } from "./session-ownership"
@@ -27,23 +26,9 @@ export const useComposerCommands = () => {
const chooseModel = async () => { const chooseModel = async () => {
const owner = sessionOwnership.capture() const owner = sessionOwnership.capture()
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
const selection = window.getSelection()
const cursor =
editor && selection?.rangeCount && editor.contains(selection.anchorNode) ? getCursorPosition(editor) : null
const restoreComposer = () => {
// Kobalte restores focus during its teardown effect; defer past it so the
// composer keeps focus and the caret returns to where the user left it.
requestAnimationFrame(() => {
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
if (!editor) return
editor.focus()
if (cursor !== null) setCursorPosition(editor, cursor)
})
}
const { DialogSelectModel } = await import("@/components/dialog-select-model") const { DialogSelectModel } = await import("@/components/dialog-select-model")
owner.run(() => { owner.run(() => {
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer) void dialog.show(() => <DialogSelectModel model={local.model} />)
}) })
} }
-116
View File
@@ -1,116 +0,0 @@
const editableSelector = "input, textarea, select, [contenteditable=''], [contenteditable='true']"
export function handleDocumentSearchKeydown(
input: HTMLInputElement | undefined,
event: KeyboardEvent,
inputValue: string,
setInputValue: (value: string) => void,
) {
if (!input) return false
if (event.defaultPrevented || event.isComposing) return false
if (event.target === input) return false
if (event.target instanceof Element && event.target.closest(editableSelector)) return false
const action = searchKeyAction(event)
if (!action) return false
event.preventDefault()
event.stopPropagation()
input.focus()
const start = input.selectionStart ?? inputValue.length
const end = input.selectionEnd ?? inputValue.length
if (action.type === "selectAll") {
input.setSelectionRange(0, inputValue.length)
return true
}
if (action.type === "move") {
moveSelection(input, inputValue, action.delta, event.shiftKey)
return true
}
if (action.type === "home") {
setBoundarySelection(input, start, 0, event.shiftKey)
return true
}
if (action.type === "end") {
setBoundarySelection(input, start, inputValue.length, event.shiftKey)
return true
}
if (action.type === "deleteBackward") {
if (start !== end)
return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue)
if (start === 0) return true
return updateValue(input, inputValue.slice(0, start - 1) + inputValue.slice(end), start - 1, setInputValue)
}
if (action.type === "deleteForward") {
if (start !== end)
return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end), start, setInputValue)
if (end === inputValue.length) return true
return updateValue(input, inputValue.slice(0, start) + inputValue.slice(end + 1), start, setInputValue)
}
return updateValue(
input,
inputValue.slice(0, start) + action.value + inputValue.slice(end),
start + action.value.length,
setInputValue,
)
}
function searchKeyAction(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
return { type: "selectAll" } as const
}
if (event.ctrlKey || event.metaKey || event.altKey) return undefined
if (event.key.length === 1) return { type: "insert", value: event.key } as const
if (event.key === "Backspace") return { type: "deleteBackward" } as const
if (event.key === "Delete") return { type: "deleteForward" } as const
if (event.key === "ArrowLeft") return { type: "move", delta: -1 } as const
if (event.key === "ArrowRight") return { type: "move", delta: 1 } as const
if (event.key === "Home") return { type: "home" } as const
if (event.key === "End") return { type: "end" } as const
return undefined
}
function moveSelection(input: HTMLInputElement, inputValue: string, delta: -1 | 1, extend: boolean) {
const start = input.selectionStart ?? inputValue.length
const end = input.selectionEnd ?? inputValue.length
if (!extend && start !== end) {
const caret = delta < 0 ? start : end
input.setSelectionRange(caret, caret)
return
}
if (!extend) {
const caret = Math.max(0, Math.min(inputValue.length, start + delta))
input.setSelectionRange(caret, caret)
return
}
const backward = input.selectionDirection === "backward"
const anchor = backward ? end : start
const focus = backward ? start : end
const next = Math.max(0, Math.min(inputValue.length, focus + delta))
input.setSelectionRange(Math.min(anchor, next), Math.max(anchor, next), next < anchor ? "backward" : "forward")
}
function setBoundarySelection(input: HTMLInputElement, anchor: number, focus: number, extend: boolean) {
if (!extend) {
input.setSelectionRange(focus, focus)
return
}
input.setSelectionRange(Math.min(anchor, focus), Math.max(anchor, focus), focus < anchor ? "backward" : "forward")
}
function updateValue(input: HTMLInputElement, value: string, caret: number, setInputValue: (value: string) => void) {
input.value = value
setInputValue(value)
input.setSelectionRange(caret, caret)
return true
}
+2 -13
View File
@@ -1955,7 +1955,6 @@ export type ModelsListOutput = {
| { | {
readonly id: string readonly id: string
readonly type: "native" readonly type: "native"
readonly package?: string
readonly url?: string readonly url?: string
readonly settings: { readonly [x: string]: JsonValue } readonly settings: { readonly [x: string]: JsonValue }
} }
@@ -2011,12 +2010,7 @@ export type ProvidersListOutput = {
readonly url?: string readonly url?: string
readonly settings?: { readonly [x: string]: JsonValue } readonly settings?: { readonly [x: string]: JsonValue }
} }
| { | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
readonly type: "native"
readonly package?: string
readonly url?: string
readonly settings: { readonly [x: string]: JsonValue }
}
readonly request: { readonly request: {
readonly headers: { readonly [x: string]: string } readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue } readonly body: { readonly [x: string]: JsonValue }
@@ -2049,12 +2043,7 @@ export type ProvidersGetOutput = {
readonly url?: string readonly url?: string
readonly settings?: { readonly [x: string]: JsonValue } readonly settings?: { readonly [x: string]: JsonValue }
} }
| { | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
readonly type: "native"
readonly package?: string
readonly url?: string
readonly settings: { readonly [x: string]: JsonValue }
}
readonly request: { readonly request: {
readonly headers: { readonly [x: string]: string } readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue } readonly body: { readonly [x: string]: JsonValue }
+35 -36
View File
@@ -8,11 +8,11 @@ The package is currently private to this workspace. Its API is designed around t
```ts ```ts
// One execution // One execution
yield * CodeMode.execute({ tools, code }) yield* CodeMode.execute({ tools, code })
// A reusable runtime // A reusable runtime
const runtime = CodeMode.make({ tools, limits }) const runtime = CodeMode.make({ tools, limits })
yield * runtime.execute(code) yield* runtime.execute(code)
// One agent-facing code tool // One agent-facing code tool
const codeTool = runtime.agentTool() const codeTool = runtime.agentTool()
@@ -55,9 +55,7 @@ const runtime = CodeMode.make({
}, },
}) })
const result = const result = yield* runtime.execute(`
yield *
runtime.execute(`
const order = await tools.orders.lookup({ id: "order_42" }) const order = await tools.orders.lookup({ id: "order_42" })
return { id: order.id, needsAttention: order.status !== "complete" } return { id: order.id, needsAttention: order.status !== "complete" }
`) `)
@@ -74,8 +72,8 @@ Successful result values are JSON-safe data. A program that returns `undefined`,
```ts ```ts
const tool = Tool.make({ const tool = Tool.make({
description, description,
input, // Effect Schema (validating) or JSON Schema (render-only) input, // Effect Schema (validating) or JSON Schema (render-only)
output, // optional; same choice output, // optional; same choice
run, run,
}) })
``` ```
@@ -91,15 +89,13 @@ The description and schemas are part of the model-visible tool contract. Keep de
Use `CodeMode.execute` for a single execution: Use `CodeMode.execute` for a single execution:
```ts ```ts
const result = const result = yield* CodeMode.execute({
yield * tools: { orders: { lookup: lookupOrder } },
CodeMode.execute({ code: `return await tools.orders.lookup({ id: "order_42" })`,
tools: { orders: { lookup: lookupOrder } }, limits: { maxToolCalls: 10 },
code: `return await tools.orders.lookup({ id: "order_42" })`, onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
limits: { maxToolCalls: 10 }, onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call), })
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
})
``` ```
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations. The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
@@ -114,10 +110,10 @@ const runtime = CodeMode.make({
limits: { timeoutMs: 30_000 }, limits: { timeoutMs: 30_000 },
}) })
runtime.catalog() // structured tool descriptions runtime.catalog() // structured tool descriptions
runtime.instructions() // model-facing syntax and tool guide runtime.instructions() // model-facing syntax and tool guide
runtime.execute(source) // ExecuteResult runtime.execute(source) // ExecuteResult
runtime.agentTool() // { name, description, input, output, execute } runtime.agentTool() // { name, description, input, output, execute }
``` ```
`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`. `catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`.
@@ -226,10 +222,10 @@ CodeMode is an orchestration language, not a general JavaScript runtime.
The limits are exactly three knobs: The limits are exactly three knobs:
| Limit | Default | Bounds | | Limit | Default | Bounds |
| ---------------- | -------------------: | -------------------------------------------------------------------- | | --- | ---: | --- |
| `timeoutMs` | none - no timeout | Wall-clock execution time. | | `timeoutMs` | none - no timeout | Wall-clock execution time. |
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. | | `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. | | `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. |
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context. No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
@@ -258,25 +254,28 @@ Two interpreter internals are fixed constants rather than knobs: at most 8 tool
Failures are data: Failures are data:
| Kind | Meaning | | Kind | Meaning |
| ----------------------- | -------------------------------------------------------------------------------------------------------- | | --- | --- |
| `ParseError` | Source is empty or cannot be parsed. | | `ParseError` | Source is empty or cannot be parsed. |
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. | | `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
| `UnknownTool` | A program referenced a tool the host did not provide. | | `UnknownTool` | A program referenced a tool the host did not provide. |
| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. | | `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. |
| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. | | `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. |
| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). | | `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). |
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. | | `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. | | `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
| `ToolFailure` | A tool refused or failed. | | `ToolFailure` | A tool refused or failed. |
| `ExecutionFailure` | The program threw or another execution error occurred. | | `ExecutionFailure` | The program threw or another execution error occurred. |
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`: Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
```ts ```ts
import { toolError } from "@opencode-ai/codemode" import { toolError } from "@opencode-ai/codemode"
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable"))) run: ({ id }) =>
authorized(id)
? loadOrder(id)
: Effect.fail(toolError("Order is unavailable"))
``` ```
Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary. Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary.
+374 -404
View File
@@ -39,7 +39,6 @@ package and was **deleted** in Wave 3 (done, see below).
From issue #34787 and design discussion. Do not relitigate these casually. From issue #34787 and design discussion. Do not relitigate these casually.
### Core direction ### Core direction
- Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention; - Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention;
the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention). the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention).
- **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and - **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and
@@ -53,7 +52,6 @@ From issue #34787 and design discussion. Do not relitigate these casually.
products/blog posts) in code, comments, commit messages, or docs in this repo. products/blog posts) in code, comments, commit messages, or docs in this repo.
### MCP / tools ### MCP / tools
- The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary - The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary
`Tool.make(...)` definitions and hands CodeMode a plain tool tree. `Tool.make(...)` definitions and hands CodeMode a plain tool tree.
- Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask). - Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask).
@@ -63,9 +61,8 @@ From issue #34787 and design discussion. Do not relitigate these casually.
`tools.<server>.<tool>` namespaces before handing them over. `tools.<server>.<tool>` namespaces before handing them over.
### Discovery / search ### Discovery / search
- **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?, - **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?,
limit? })` over the final tool tree, owned by this package. limit? })` over the final tool tree, owned by this package.
- Search result item shape: `{ path, description, signature }` in an `{ items, total }` - Search result item shape: `{ path, description, signature }` in an `{ items, total }`
wrapper. The `signature` string embeds the full input/output TypeScript types - in search wrapper. The `signature` string embeds the full input/output TypeScript types - in search
results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema
@@ -85,7 +82,6 @@ limit? })` over the final tool tree, owned by this package.
- Tools without an output schema render `unknown` as their return type. - Tools without an output schema render `unknown` as their return type.
### Schemas / Tool.make ### Schemas / Tool.make
- `Tool.make` carries rich metadata so search can render real signatures. - `Tool.make` carries rich metadata so search can render real signatures.
- Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially - Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially
render-only - used for TypeScript rendering; the adapter may validate on its own). Leave render-only - used for TypeScript rendering; the adapter may validate on its own). Leave
@@ -94,7 +90,6 @@ limit? })` over the final tool tree, owned by this package.
normalization for plugin authors can come later. normalization for plugin authors can come later.
### Attachments / output ### Attachments / output
- **No `output.text/file/image` API in v1.** (Deleted in Wave 2.) - **No `output.text/file/image` API in v1.** (Deleted in Wave 2.)
- Tool calls return native structured payloads into the sandbox. Files/images emitted by - Tool calls return native structured payloads into the sandbox. Files/images emitted by
child tools **never enter the sandbox** - the OpenCode adapter strips and accumulates them child tools **never enter the sandbox** - the OpenCode adapter strips and accumulates them
@@ -105,7 +100,6 @@ limit? })` over the final tool tree, owned by this package.
image bytes into context or drop attachments. image bytes into context or drop attachments.
### Runtime behavior ### Runtime behavior
- Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` - - Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` -
matching the original locked spec exactly. NO limit has a default (user direction, Fix 6 matching the original locked spec exactly. NO limit has a default (user direction, Fix 6
for the first two; extended to `maxOutputBytes` in the truncation-layering fix below): for the first two; extended to `maxOutputBytes` in the truncation-layering fix below):
@@ -135,7 +129,7 @@ limit? })` over the final tool tree, owned by this package.
- `console.*` is captured into `logs` on the result; the host appends them to model-facing - `console.*` is captured into `logs` on the result; the host appends them to model-facing
output. Not a tool call; costs no tool budget. output. Not a tool call; costs no tool budget.
- Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name, - Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name,
input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`. input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`.
Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in
Wave 2). Wave 2).
@@ -154,9 +148,8 @@ and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and
`test/tool/registry.test.ts`). `test/tool/registry.test.ts`).
### Wave 0 - scaffold (done) ### Wave 0 - scaffold (done)
- `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool, - `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool,
tool-error,tool-runtime}.ts`, README, AGENTS.md, tests. tool-error,tool-runtime}.ts`, README, AGENTS.md, tests.
- `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`, - `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`,
`effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only `effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only
touches `unstable/httpapi`, which this package doesn't use). touches `unstable/httpapi`, which this package doesn't use).
@@ -164,7 +157,6 @@ tool-error,tool-runtime}.ts`, README, AGENTS.md, tests.
Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`. Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`.
### Wave 1a - forgiving JS semantics (done) ### Wave 1a - forgiving JS semantics (done)
Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance
spec. The seeded interpreter was deliberately strict; these behaviors replaced that: spec. The seeded interpreter was deliberately strict; these behaviors replaced that:
@@ -181,7 +173,6 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t
null/undefined still throws (real JS throws too). null/undefined still throws (real JS throws too).
### Wave 1b-i - stdlib value types: Date, RegExp, Map, Set (done) ### Wave 1b-i - stdlib value types: Date, RegExp, Map, Set (done)
`src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both `src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both
`codemode.ts` and `tool-runtime.ts` import without a cycle). Design: `codemode.ts` and `tool-runtime.ts` import without a cycle). Design:
@@ -211,20 +202,19 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t
interpolation renders `/regex/` and ISO dates directly. interpolation renders `/regex/` and ISO dates directly.
### Wave 2 - API layer (done) ### Wave 2 - API layer (done)
The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this
wave; both packages typecheck clean. wave; both packages typecheck clean.
- **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect - **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect
Schema (validating, decoded both directions as before) OR a raw JSON Schema document Schema (validating, decoded both directions as before) OR a raw JSON Schema document
(render-only - no validation, values pass through; rendering handles `$defs`/`definitions` (render-only - no validation, values pass through; rendering handles `$defs`/`definitions`
- `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host + `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/ `tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there `jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) - `Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
cosmetic, fixed in Wave 4. cosmetic, fixed in Wave 4.
- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output` - **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output`
global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields, global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields,
instructions line, README section, seeded tests. AGENTS.md keeps a rephrased instructions line, README section, seeded tests. AGENTS.md keeps a rephrased
@@ -238,23 +228,22 @@ wave; both packages typecheck clean.
(`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls (`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls
fire no end event (timeout kills the whole execution anyway). fire no end event (timeout kills the whole execution anyway).
- **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?, - **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?,
maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as
internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5 internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5
later deleted that type and the internal limit system entirely. later deleted that type and the internal limit system entirely.
- **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in - **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in
a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized
serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte
output limit; return a smaller value]`; logs keep leading lines within the remaining budget output limit; return a smaller value]`; logs keep leading lines within the remaining budget
- `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to + `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to
`ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox `ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox
`maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 - `maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 -
truncation is now the only result-size mechanism.) truncation is now the only result-size mechanism.)
- **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a - **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a
trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone
(`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged. (`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged.
### Wave 3 - OpenCode MCP adapter (done) ### Wave 3 - OpenCode MCP adapter (done)
`packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package; `packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package;
the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so
`tools.ts` gating (flag on + MCP tools exist -> single `execute` tool, early-return suppresses `tools.ts` gating (flag on + MCP tools exist -> single `execute` tool, early-return suppresses
@@ -268,7 +257,7 @@ per-MCP registration; MCP resource tools unaffected) is unchanged.
invoked) - so signature rendering, the inline-vs-search switch, and `$codemode.search` invoked) - so signature rendering, the inline-vs-search switch, and `$codemode.search`
availability all come from this package and stay consistent with execution. availability all come from this package and stay consistent with execution.
- **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns: - **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns:
["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child). ["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child).
Denials and host failures are mapped to `toolError(message)` so they surface as safe, Denials and host failures are mapped to `toolError(message)` so they surface as safe,
catchable in-program failures (MCP `isError` text propagates as `e.message`; without this catchable in-program failures (MCP `isError` text propagates as `e.message`; without this
they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from
@@ -281,10 +270,10 @@ per-MCP registration; MCP resource tools unaffected) is unchanged.
No handles, no `Result<T>` envelope, no base64 in the sandbox, no data-size tuning (the No handles, no `Result<T>` envelope, no base64 in the sandbox, no data-size tuning (the
`maxDataBytes` budget that existed at the time was deleted in Fix 5). `maxDataBytes` budget that existed at the time was deleted in Fix 5).
- **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND - **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND
error - logs are plain pre-formatted lines now), attachments: accumulated }` through the error - logs are plain pre-formatted lines now), attachments: accumulated }` through the
existing `Tool.ExecuteResult.attachments` -> `message-v2.ts` vision plumbing; attachments existing `Tool.ExecuteResult.attachments` -> `message-v2.ts` vision plumbing; attachments
ride on both success and error results. Diagnostic `suggestions` not already contained in ride on both success and error results. Diagnostic `suggestions` not already contained in
the message are appended to error output. Native outer truncation stays on (adapter never the message are appended to error output. Native outer truncation stays on (adapter never
sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time) sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time)
cut first - since the truncation-layering fix, native truncation is the only layer. cut first - since the truncation-layering fix, native truncation is the only layer.
Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout); Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout);
@@ -307,7 +296,6 @@ error - logs are plain pre-formatted lines now), attachments: accumulated }` thr
describe/`renderType`/`rankTools` tests died with the old design (58+17+24 -> 34+16). describe/`renderType`/`rankTools` tests died with the old design (58+17+24 -> 34+16).
### Wave 4 - instructions/prompting + polish (done) ### Wave 4 - instructions/prompting + polish (done)
Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a
real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both
packages typecheck clean. packages typecheck clean.
@@ -333,7 +321,7 @@ packages typecheck clean.
exported); `CodeMode.execute` (one-shot) passes it too, preserving the exported); `CodeMode.execute` (one-shot) passes it too, preserving the
`execute`==`make().execute` law. A speculative `tools.$codemode.search` call on a small `execute`==`make().execute` law. A speculative `tools.$codemode.search` call on a small
catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at
search. Search is _advertised_ in the instructions only when the inlined list is PARTIAL, search. Search is *advertised* in the instructions only when the inlined list is PARTIAL,
keeping small-catalog instructions tight. keeping small-catalog instructions tight.
- **Prompting content** in `instructions()`, mapping 1:1 to the section 5 transcript failures: - **Prompting content** in `instructions()`, mapping 1:1 to the section 5 transcript failures:
parse-string-results-as-JSON, return-small, console-for-intermediates, and parse-string-results-as-JSON, return-small, console-for-intermediates, and
@@ -352,7 +340,7 @@ packages typecheck clean.
- **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`, - **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`,
the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory, the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory,
sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run
--dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single --dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single
`execute` tool registered alongside core tools (per-MCP registration suppressed; MCP `execute` tool registered alongside core tools (per-MCP registration suppressed; MCP
resource tools unaffected); the live description read back as "Available tools (PARTIAL - resource tools unaffected); the live description read back as "Available tools (PARTIAL -
56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace 56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace
@@ -364,7 +352,6 @@ packages typecheck clean.
images, output truncation. images, output truncation.
### Wave 5 - Promise generalization (done) ### Wave 5 - Promise generalization (done)
First-class promise values in the interpreter; the direct-tool-call-only `Promise.all` First-class promise values in the interpreter; the direct-tool-call-only `Promise.all`
restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new
in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode
@@ -508,7 +495,7 @@ adapter needed **no changes**.
`tools.*`." (the second line drops the tools clause when the tree is empty). `tools.*`." (the second line drops the tools clause when the tree is empty).
- **`## Workflow`**: numbered steps - find a tool via `tools.$codemode.search` -> read - **`## Workflow`**: numbered steps - find a tool via `tools.$codemode.search` -> read
the `{ path, description, signature }` matches -> call by path -> `typeof res === the `{ path, description, signature }` matches -> call by path -> `typeof res ===
"string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is "string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is
COMPLETE the search/read steps collapse into "Pick a tool from the list under COMPLETE the search/read steps collapse into "Pick a tool from the list under
`## Available tools`" and the steps renumber (4 instead of 5). `## Available tools`" and the steps renumber (4 instead of 5).
- **`## Rules`**: call-by-exact-path; TEXT-is-JSON -> JSON.parse; return small (never raw - **`## Rules`**: call-by-exact-path; TEXT-is-JSON -> JSON.parse; return small (never raw
@@ -539,72 +526,70 @@ adapter needed **no changes**.
**Fix 4 - token-budgeted catalog (was bytes)** (user direction: signatures need a token **Fix 4 - token-budgeted catalog (was bytes)** (user direction: signatures need a token
budget; namespaces must always be present): budget; namespaces must always be present):
- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so
- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so the package stays dependency-free; keep in sync if the core heuristic changes.
the package stays dependency-free; keep in sync if the core heuristic changes. - `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000 estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size
estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first
reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first + stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
- stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
Fix 8). Namespace stub lines were and remain unbudgeted - every Fix 8). Namespace stub lines were and remain unbudgeted - every
namespace always appears with its tool count, even at budget 0 (asserted in package and namespace always appears with its tool count, even at budget 0 (asserted in package and
adapter tests). adapter tests).
- Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall - Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall
to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the
lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements
(2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed; (2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed;
worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens. worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens.
**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as **Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as
configurable knobs; the internal limit system dies): configurable knobs; the internal limit system dies):
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
the time; Fix 6 later removed the first two defaults. Same validation: safe integers, timeoutMs >= 1, others >= 0, RangeError otherwise) is now
timeoutMs >= 1, others >= 0, RangeError otherwise) is now the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
the ENTIRE limit surface - exactly the shape section 2's original locked spec named. `ResolvedExecutionLimits` shrank to those three fields; the `@internal`
`ResolvedExecutionLimits` shrank to those three fields; the `@internal` `InternalExecutionLimits` type is deleted.
`InternalExecutionLimits` type is deleted. - **Deleted outright**: `maxOperations` and the whole operation-budget machinery
- **Deleted outright**: `maxOperations` and the whole operation-budget machinery (`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/
(`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/ `cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check);
`cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check); `maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`,
`maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`, the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes`
the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes` fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte
fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and
checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged);
audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged); `maxCollectionLength` (every array-length/object-field-count check - this knob was
`maxCollectionLength` (every array-length/object-field-count check - this knob was actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded` and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and `ExecuteResultSchema` (fine - the package is unreleased).
`ExecuteResultSchema` (fine - the package is unreleased). - **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork
- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept
semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept only because it produces a clearer error than a native stack-overflow RangeError; still
only because it produces a clearer error than a native stack-overflow RangeError; still `InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone -
`InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone - `copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just
`copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just `(tools, maxToolCalls, hooks?, searchIndex?)`.
`(tools, maxToolCalls, hooks?, searchIndex?)`. - **Verified fact**: timeout interruption does NOT depend on the operation budget - the
- **Verified fact**: timeout interruption does NOT depend on the operation budget - the Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts
Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at
even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at ~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression
~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` ->
test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` -> `TimeoutExceeded`, elapsed well under a few seconds).
`TimeoutExceeded`, elapsed well under a few seconds). - **Kept (correctness, not budgets)**: circular detection (`copyIn` walks +
- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks + `rejectCircularInsertion` on mutations), plain-objects-only, blocked properties
`rejectCircularInsertion` on mutations), plain-objects-only, blocked properties (`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit
(`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit behaviors unchanged.
behaviors unchanged. - Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels
- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels now fail at the data boundary (`copyIn`) instead of at construction; array index
now fail at the data boundary (`copyIn`) instead of at construction; array index assignment allows any non-negative integer index (holes permitted, message now "must be
assignment allows any non-negative integer index (holes permitted, message now "must be a non-negative integer"); interpreter-produced deep/hostile structures that overflow the
a non-negative integer"); interpreter-produced deep/hostile structures that overflow the native stack during a walk still normalize to the existing "Execution exceeded the
native stack during a walk still normalize to the existing "Execution exceeded the maximum nesting depth." data diagnostic - failures remain data everywhere.
maximum nesting depth." data diagnostic - failures remain data everywhere. - Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2,
- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2, enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/ maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit
maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit test - superseded by the package timeout regression test); rewrote the helpers that used
test - superseded by the package timeout regression test); rewrote the helpers that used `InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits` (promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter suites: 34 + 16.
suites: 34 + 16.
**Fix 6 - no default timeout / tool-call cap** (user direction): `timeoutMs` and **Fix 6 - no default timeout / tool-call cap** (user direction): `timeoutMs` and
`maxToolCalls` lost their defaults (were 10_000 / 100) - absent now means no timeout / `maxToolCalls` lost their defaults (were 10_000 / 100) - absent now means no timeout /
@@ -654,200 +639,196 @@ adapter suites: 34 + 16.
**Fix 8 - condensed instructions + round-robin catalog fairness + plural-aware search** **Fix 8 - condensed instructions + round-robin catalog fairness + plural-aware search**
(user direction: the fixed instruction prose was too verbose; two discovery fixes ride (user direction: the fixed instruction prose was too verbose; two discovery fixes ride
along). All in `tool-runtime.ts`; no interpreter changes. along). All in `tool-runtime.ts`; no interpreter changes.
- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens)
- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens) are replaced by four short lines (~188) built on "models already know JavaScript; name
are replaced by four short lines (~188) built on "models already know JavaScript; name only what is unusual or missing": (1) standard modern JS works - functions/closures,
only what is unusual or missing": (1) standard modern JS works - functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the
destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and
usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are
Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are stripped before execution, decorators are not supported; (3) NOT supported (each fails
stripped before execution, decorators are not supported; (3) NOT supported (each fails with a message naming the alternative): classes, generators, for await...of,
with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors
.then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates ->
are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates -> ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter
ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter before writing: probed empirically - classes/generators/for-await/.then/.catch/
before writing: probed empirically - classes/generators/for-await/.then/.catch/ .finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged
.finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged templates/object getters all fail with clear diagnostics; TS annotations/`as`/
templates/object getters all fail with clear diagnostics; TS annotations/`as`/ interfaces/type aliases are stripped and TS **enums actually work** (transpileModule
interfaces/type aliases are stripped and TS **enums actually work** (transpileModule compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned.
compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned. `supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched.
`supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched. - **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and
- **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and return-small content now lives ONLY in the numbered Workflow steps (with their
return-small content now lives ONLY in the numbered Workflow steps (with their compliance-driving justifications inline: "most tools return JSON as a string", "raw
compliance-driving justifications inline: "most tools return JSON as a string", "raw payloads get truncated and waste context"); Rules keeps only bullets adding new
payloads get truncated and waste context"); Rules keeps only bullets adding new content - filter/aggregate collections in code, console.* intermediates (logs ride
content - filter/aggregate collections in code, console.\* intermediates (logs ride back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace
back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace (PARTIAL only), and the media rule compressed to one line. The no-.then/.catch
(PARTIAL only), and the media rule compressed to one line. The no-.then/.catch guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search
guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search step gained query-style guidance (`- short phrases like "list issues" work best`; a
step gained query-style guidance (`- short phrases like "list issues" work best`; a clearly-a-query-string example, not a tool name), and the exact-path guidance is now
clearly-a-query-string example, not a tool name), and the exact-path guidance is now "call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it
"call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it as-is rather than guessing segments".
as-is rather than guessing segments". - **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0,
- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0, bytes/3.7 - same method as Fix 4; chars/4 in parentheses):
bytes/3.7 - same method as Fix 4; chars/4 in parentheses): preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191
preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191 (332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562),
(332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562), ~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it
~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it absorbed the deduped parse/return-small justifications.
absorbed the deduped parse/return-small justifications. - **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss
- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss behavior (alphabetically-late namespaces starved to "none shown" while an early
behavior (alphabetically-late namespaces starved to "none shown" while an early namespace inlines everything) is replaced by round-robin fairness - in each round
namespace inlines everything) is replaced by round-robin fairness - in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to
(namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest line against the shared token budget; a namespace whose next
place its next-cheapest line against the shared token budget; a namespace whose next line does not fit is done while the others keep going; stop when all are done. Every
line does not fit is done while the others keep going; stop when all are done. Every namespace gets some representation before any namespace gets everything. Kept:
namespace gets some representation before any namespace gets everything. Kept: `estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace
`estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace `(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL
`(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL header, alphabetical namespace order in the output, cheapest-first within each
header, alphabetical namespace order in the output, cheapest-first within each namespace's shown set.
namespace's shown set. - **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must
- **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must be substring of indexed text), so query "issues" missed a tool whose text only says
be substring of indexed text), so query "issues" missed a tool whose text only says "issue". Now each term expands to `termForms` - the term plus naive singular variants
"issue". Now each term expands to `termForms` - the term plus naive singular variants (trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of
(trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of the four field checks passes when ANY form matches. Weights, exact-path lookup, and
the four field checks passes when ANY form matches. Weights, exact-path lookup, and namespace scoping untouched. A true plural path match still outranks a singular-only
namespace scoping untouched. A true plural path match still outranks a singular-only description match (path substring 8 + searchable 2 > description 4 + searchable 2).
description match (path substring 8 + searchable 2 > description 4 + searchable 2). - **Tests**: package instruction/structure assertions updated to the new text; new
- **Tests**: package instruction/structure assertions updated to the new text; new syntax-section test (leads with "Standard modern JavaScript works", names the
syntax-section test (leads with "Standard modern JavaScript works", names the verified not-supported list, keeps the data-boundary note); the budget-exhaustion
verified not-supported list, keeps the data-boundary note); the budget-exhaustion test rewritten to assert the new fairness (alpha.expensive not fitting must NOT
test rewritten to assert the new fairness (alpha.expensive not fitting must NOT prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new
prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new plural/singular test (query "issues" finds a singular-only tool; ranking still
plural/singular test (query "issues" finds a singular-only tool; ranking still prefers the true "issues" path match). Adapter: description assertions updated; the
prefers the true "issues" path match). Adapter: description assertions updated; the large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` +
large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` + its inlined line) - it was "none shown" under starvation. README updated (budgeted
its inlined line) - it was "none shown" under starvation. README updated (budgeted catalog paragraph -> round-robin; search paragraph -> singular variants;
catalog paragraph -> round-robin; search paragraph -> singular variants; instructions-structure paragraph -> new section contents). Package suite: 169 pass /
instructions-structure paragraph -> new section contents). Package suite: 169 pass / 0 fail; adapter suites: 34 + 16.
0 fail; adapter suites: 34 + 16.
**Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed **Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed
instructions and directed further cuts): instructions and directed further cuts):
- Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures
- Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces).
auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces). - Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single
- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single `unknown`-treatment warning: "A result typed `Promise<unknown>` has no guaranteed
`unknown`-treatment warning: "A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields." (Deliberately
shape - verify what actually came back before relying on its fields." (Deliberately does NOT suggest console.log - user review: naming it there nudges models to log AND
does NOT suggest console.log - user review: naming it there nudges models to log AND return the same data; the prompt stays console-neutral, neither for nor against.)
return the same data; the prompt stays console-neutral, neither for nor against.) The media-stripping MECHANISM is unchanged and still tested; only the prose about it
The media-stripping MECHANISM is unchanged and still tested; only the prose about it is gone - the `[N images attached]` marker is self-explanatory in context.
is gone - the `[N images attached]` marker is self-explanatory in context. - Kept as-is per user: the JSON.parse workflow step (maps to the original motivating
- Kept as-is per user: the JSON.parse workflow step (maps to the original motivating transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule
transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule (undecided), no no-fetch/ambient-authority rule added (proposed, not approved).
(undecided), no no-fetch/ambient-authority rule added (proposed, not approved). - Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter
- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged
boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged as a next-iteration follow-up below.
as a next-iteration follow-up below.
**DSL-expansion pass - interpreter-surface batch from section 4** (the deferred medium-tier JS **DSL-expansion pass - interpreter-surface batch from section 4** (the deferred medium-tier JS
parity items, done as one focused pass; no public API or limit changes): parity items, done as one focused pass; no public API or limit changes):
- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`,
- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are
`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` ->
bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` -> `"function"`). Error values stay the same plain `{ name, message }` null-prototype
`"function"`). Error values stay the same plain `{ name, message }` null-prototype objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol
objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread,
key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread, JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the
JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the brand is lost on spread/boundary copies exactly like JS loses the prototype.
brand is lost on spread/boundary copies exactly like JS loses the prototype. `caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so
`caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so caught interpreter AND tool failures are `instanceof Error` and carry the `name` the
caught interpreter AND tool failures are `instanceof Error` and carry the `name` the equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real
equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set
JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and
fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and now include the engine's position detail in the message; safe - derived from the
now include the engine's position detail in the message; safe - derived from the program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown
program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`,
identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`, a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly
a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly keeps its own name when it is one of the standard seven. Tool failures and everything
keeps its own name when it is one of the standard seven. Tool failures and everything without a specific analogue stay `"Error"` - internal class names never leak. Specific
without a specific analogue stay `"Error"` - internal class names never leak. Specific names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS.
names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS. The operator is handled in `evaluateBinaryExpression`
The operator is handled in `evaluateBinaryExpression` BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and
BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and functions included); recognized rhs: the error constructors (a specific type matches its
functions included); recognized rhs: the error constructors (a specific type matches its own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes),
own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes), `Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and
`Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and `Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a
`Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a catchable error naming the recognized constructors.
catchable error naming the recognized constructors. - **Array methods**: `splice` (mutating, returns the removed elements; insertions run
- **Array methods**: `splice` (mutating, returns the removed elements; insertions run `rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined
`rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined delete count removes nothing), `fill` (circular-checked value) and `copyWithin`
delete count removes nothing), `fill` (circular-checked value) and `copyWithin` (host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set
(host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set convention - for...of and spread work either way). The `retryableArrayMethods`
convention - for...of and spread work either way). The `retryableArrayMethods` "rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown
"rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown array properties still read `undefined`.
array properties still read `undefined`. - **String methods**: `localeCompare(that)` (locale/options arguments ignored - host
- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form
default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form -> catchable error naming the four valid forms), `trimLeft`/`trimRight` as
-> catchable error naming the four valid forms), `trimLeft`/`trimRight` as trimStart/trimEnd aliases.
trimStart/trimEnd aliases. - **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the
- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the offending pattern (or flags) plus the engine reason (deduped "Invalid regular
offending pattern (or flags) plus the engine reason (deduped "Invalid regular expression:" prefix via `regexFailureReason`) and a shared escaping hint
expression:" prefix via `regexFailureReason`) and a shared escaping hint (`escapeRegexHint`); flags failures list the valid flag letters; the
(`escapeRegexHint`); flags failures list the valid flag letters; the replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and
replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and the single-match alternative.
the single-match alternative. - **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues =
- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues = false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox
false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template
checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`,
interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`, which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by
which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by reference as leaves** (contents not walked - Map/Set members are validated at their
reference as leaves** (contents not walked - Map/Set members are validated at their mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity,
mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity, plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep
plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was
the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was considered and skipped to preserve the nudge). The HOST boundary (final result,
considered and skipped to preserve the nudge). The HOST boundary (final result, tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and
tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on
still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on the preserving path are defensively wrapped into sandbox equivalents. Ripple: the
the preserving path are defensively wrapped into sandbox equivalents. Ripple: the `Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`,
`Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`, assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props
assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the
there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread
template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread already preserved instances (reference copies, no checkpoint) - now tested.
already preserved instances (reference copies, no checkpoint) - now tested. - **Console formatting**: `formatConsoleArgument` is total and deep
- **Console formatting**: `formatConsoleArgument` is total and deep (`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity`
(`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity` literally - never the JSON `null`; finite numbers match their JSON form), nested
literally - never the JSON `null`; finite numbers match their JSON form), nested strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO
strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become
date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become in-place `[CodeMode reference]` markers instead of collapsing the whole argument,
in-place `[CodeMode reference]` markers instead of collapsing the whole argument, cycles render `[Circular]` (reachable via Map/Set members, which mutation never
cycles render `[Circular]` (reachable via Map/Set members, which mutation never checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob)
checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob) degrades to `...` - console can no longer fail a program. `console.table` guards with
degrades to `...` - console can no longer fail a program. `console.table` guards with `containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell
`containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell walkers treat sandbox values as scalar cells.
walkers treat sandbox values as scalar cells. - **Prose**: the instructions Syntax not-supported line dropped its `instanceof
- **Prose**: the instructions Syntax not-supported line dropped its `instanceof Error`/splice mentions (nothing else reworded); README updated (checkpoint
Error`/splice mentions (nothing else reworded); README updated (checkpoint preservation vs boundary serialization, error values/`instanceof`, new array/string
preservation vs boundary serialization, error values/`instanceof`, new array/string methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists
methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists supported syntax, was already non-exhaustive, and stays accurate).
supported syntax, was already non-exhaustive, and stays accurate). - **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name
- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias
coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib
describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib `instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console
`instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged
rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged (34 + 16, green); both packages `tsgo --noEmit` clean.
(34 + 16, green); both packages `tsgo --noEmit` clean.
**Truncation layering - CodeMode truncation off in OpenCode** (user direction; resolves the **Truncation layering - CodeMode truncation off in OpenCode** (user direction; resolves the
section 4 outer-truncation item the OPPOSITE way from "kill the outer one"): section 4 outer-truncation item the OPPOSITE way from "kill the outer one"):
- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two
- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two limits: absent = no truncation. All three limits are uniformly no-default - budgets are
limits: absent = no truncation. All three limits are uniformly no-default - budgets are host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`;
host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`; `boundOutput` only runs when the host set the limit. Explicit values validate as before
`boundOutput` only runs when the host set the limit. Explicit values validate as before (safe integer >= 0).
(safe integer >= 0). - OpenCode continues to pass NO limits, which now also means no CodeMode truncation.
- OpenCode continues to pass NO limits, which now also means no CodeMode truncation. `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation
`execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`,
applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`, 50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under
50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under `tool-output/`): the `metadata.truncated` self-truncation exemption never fires for
`tool-output/`): the `metadata.truncated` self-truncation exemption never fires for `execute` (its metadata never sets that key). One truncation layer, the host's - and it
`execute` (its metadata never sets that key). One truncation layer, the host's - and it is the richer one (file dump + explore/grep hint vs an inline marker).
is the richer one (file dump + explore/grep hint vs an inline marker). - Hosts without their own output bounding set `maxOutputBytes` explicitly; README table
- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB
and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test
value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test that relied on the old default now asserts the oversized result reaches the shared
that relied on the old default now asserts the oversized result reaches the shared wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
**Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default **Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default
4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality) 4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality)
@@ -856,137 +837,132 @@ regular dependency; hosts depend on it themselves because the API surface is Eff
**Registry promotion + permission-aware catalog** (the "promote to a proper tool service" **Registry promotion + permission-aware catalog** (the "promote to a proper tool service"
restructure; fixes the section 4 permission-advertising bug): restructure; fixes the section 4 permission-advertising bug):
- **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a
- **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a registry-resident tool service on the TaskTool precedent: `CodeModeTool =
registry-resident tool service on the TaskTool precedent: `CodeModeTool = Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`,
Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`, and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by
and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by `flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the
`flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The
registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` +
session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` + append) is deleted; the early return that suppresses raw per-MCP registration when the
append) is deleted; the early return that suppresses raw per-MCP registration when the flag is on stays session-side, keyed on the same flag+tool-count condition.
flag is on stays session-side, keyed on the same flag+tool-count condition. - **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP
- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP tool count is consulted once (an Effect) before the synchronous filter, and code mode
tool count is consulted once (an Effect) before the synchronous filter, and code mode passes the predicate iff `flags.experimentalCodeMode` && count > 0.
passes the predicate iff `flags.experimentalCodeMode` && count > 0. - **Description split on the `describeTask` precedent**: the tool's static base
- **Description split on the `describeTask` precedent**: the tool's static base description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()`
description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()` appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog,
appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog, `catalogInstructions` in the adapter) at the same composition point as task - so
`catalogInstructions` in the adapter) at the same composition point as task - so `plugin.trigger("tool.definition")` sees the base description first.
`plugin.trigger("tool.definition")` sees the base description first. - **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from
- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from `llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)`
`llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)` (a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"`
(a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"` hides a tool; ask-level rules stay fully visible and prompt at call time) and
hides a tool; ask-level rules stay fully visible and prompt at call time) and `resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters
`resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters with the merged agent+session ruleset that `SessionTools.resolve` passes into the
with the merged agent+session ruleset that `SessionTools.resolve` passes into the registry before building the catalog/search index; `execute` rebuilds the runtime per
registry before building the catalog/search index; `execute` rebuilds the runtime per execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset
execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset (`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge
(`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge `SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable
`SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable even if the model guesses its name and yields the normal unknown-tool diagnostic.
even if the model guesses its name and yields the normal unknown-tool diagnostic. Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives
Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives at request-prep after descriptions are built and has no child-call equivalent.
at request-prep after descriptions are built and has no child-call equivalent. - **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult`
- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult` unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution
unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired
limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired through `Tool.Context` exactly like every registry tool).
through `Tool.Context` exactly like every registry tool). - **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation,
- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation, permission ruleset) was considered and deliberately skipped - the per-turn rebuild is
permission ruleset) was considered and deliberately skipped - the per-turn rebuild is cheap (grouping + string rendering); revisit only if profiling shows it matters.
cheap (grouping + string rendering); revisit only if profiling shows it matters. - **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration}
- **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration} .test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct
.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct `define(...)` construction; description assertions target `catalogInstructions`, the
`define(...)` construction; description assertions target `catalogInstructions`, the registry's composition input) and gained permission coverage: deny excluded from
registry's composition input) and gained permission coverage: deny excluded from catalog/search, ask-level stays visible and callable, denied tool undispatchable
catalog/search, ask-level stays visible and callable, denied tool undispatchable (unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/
(unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/ registry.test.ts` gained four registry-level tests: registered with flag+MCP tools,
registry.test.ts` gained four registry-level tests: registered with flag+MCP tools, excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering
excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green.
through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green.
**Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the section 4 "plugin hooks skip **Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the section 4 "plugin hooks skip
child calls" gap): child calls" gap):
- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool"
- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool" middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook ->
middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook -> permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's
permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's `ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute`
`ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute` tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) ->
tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) -> plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute
plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in
resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in `SessionTools.resolve` applies its existing model-facing shaping/truncation, code
`SessionTools.resolve` applies its existing model-facing shaping/truncation, code mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers
mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool,
already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool, not about sessions or code mode.
not about sessions or code mode. - **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result -
- **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result - which is exactly what the legacy loop always passed (the raw `CallToolResult`, not
which is exactly what the legacy loop always passed (the raw `CallToolResult`, not the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit
the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit and the hook payload cannot drift between callers. No callback/edge-firing design
and the hook payload cannot drift between callers. No callback/edge-firing design was needed.
was needed. - **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the
- **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to
hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to the entry key; `n` = per-execution counter starting at 1, shared across all child
the entry key; `n` = per-execution counter starting at 1, shared across all child calls in one program). callID is an opaque string - nothing parses it. The ai-sdk
calls in one program). callID is an opaque string - nothing parses it. The ai-sdk `toolCallId` (`options.toolCallId`) stays each caller's existing value
`toolCallId` (`options.toolCallId`) stays each caller's existing value (`ctx.callID ?? entry.key` for code mode).
(`ctx.callID ?? entry.key` for code mode). - **Child-scoped hook failures**: `CodeModeTool` (which now also yields
- **Child-scoped hook failures**: `CodeModeTool` (which now also yields `Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in
`Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in `toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin
`toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin hook failure fails ONLY that child call as a catchable in-program `toolError`; other
hook failure fails ONLY that child call as a catchable in-program `toolError`; other calls in the same program keep running and interruption still propagates as
calls in the same program keep running and interruption still propagates as interruption. Legacy semantics unchanged: a hook failure fails the tool call.
interruption. Legacy semantics unchanged: a hook failure fails the tool call. - **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the
- **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a
MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a failing before hook is caught in-program, gates dispatch, and leaves the outer
failing before hook is caught in-program, gates dispatch, and leaves the outer execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through
execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins
trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins `SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer):
`SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer): flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw
flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw keys present, `execute` absent; and the legacy raw-MCP execute fires before/after
keys present, `execute` absent; and the legacy raw-MCP execute fires before/after hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter
hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter 45 + 16, session/tool/permission all green; this package untouched (211 pass).
45 + 16, session/tool/permission all green; this package untouched (211 pass).
**Signature rendering + compound-assignment parity fixes** (externally reported, both **Signature rendering + compound-assignment parity fixes** (externally reported, both
verified real with failing tests before fixing): verified real with failing tests before fixing):
- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema`
- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema` emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123`
emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123` rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper - bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the single `field` closure both the compact and pretty renderings share. The
single `field` closure both the compact and pretty renderings share. The `identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s
`identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s bracket-notation `toolExpression` imports it: one source of truth for "is this a bare
bracket-notation `toolExpression` imports it: one source of truth for "is this a bare identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact,
identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact, pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct).
pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct). - **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old
- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old `anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just `number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`, etc.). The collapse is now restricted to Effect's number-schema artifact
etc.). The collapse is now restricted to Effect's number-schema artifact (`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums),
(`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums), while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3.
while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3. - **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`):
- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`): `applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y`
`applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y` diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`;
diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`; `d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved
`d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`;
verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`; compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and
compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`)
dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`) keep their separate short-circuit path (`evaluateLogicalAssignment`), and both
keep their separate short-circuit path (`evaluateLogicalAssignment`), and both assignment call sites still wrap results in `boundedData`. Deliberate side effect:
assignment call sites still wrap results in `boundedData`. Deliberate side effect: compound assignment now rejects opaque references, consistent with binary operators.
compound assignment now rejects opaque references, consistent with binary operators. Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity,
Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity, string `+=` object/array, member-target compound, 13-case operator sweep vs real JS).
string `+=` object/array, member-target compound, 13-case operator sweep vs real JS). Package suite: 220 pass.
Package suite: 220 pass.
--- ---
## 4. Remaining work (detailed TODO) ## 4. Remaining work (detailed TODO)
### Next DSL-expansion pass (done - see the DSL-expansion pass entry in section 3) ### Next DSL-expansion pass (done - see the DSL-expansion pass entry in section 3)
Batch these together - per user direction: important, but deliberately deferred to one Batch these together - per user direction: important, but deliberately deferred to one
focused interpreter-surface pass rather than picked off piecemeal. focused interpreter-surface pass rather than picked off piecemeal.
- [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain - [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain
`{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value - `{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value -
`x instanceof Error` is unsupported syntax); `splice` (still a `x instanceof Error` is unsupported syntax); `splice` (still a
@@ -1005,7 +981,6 @@ focused interpreter-surface pass rather than picked off piecemeal.
(`console.log({ m: map })`) - could deep-format instead. (`console.log({ m: map })`) - could deep-format instead.
### Next iteration: text-result handling (deliberate follow-up, user-directed) ### Next iteration: text-result handling (deliberate follow-up, user-directed)
- [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the - [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the
server sends it, else joined text as a plain string (the program JSON.parses it, server sends it, else joined text as a plain string (the program JSON.parses it,
guided by a workflow step). Considered and deferred: (a) conservative boundary guided by a workflow step). Considered and deferred: (a) conservative boundary
@@ -1017,7 +992,6 @@ focused interpreter-surface pass rather than picked off piecemeal.
revisit once real usage shows which failure modes matter. revisit once real usage shows which failure modes matter.
### Next iteration: stdlib surface (prioritized) ### Next iteration: stdlib surface (prioritized)
Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is
intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host
runtime, but close the high-friction gaps models are likely to reach for. runtime, but close the high-friction gaps models are likely to reach for.
@@ -1054,9 +1028,7 @@ Explicit non-goals for now: `structuredClone`, `WeakMap`/`WeakSet`, and timers
orchestration use case. orchestration use case.
### Wiring-review findings (subagent code review of the OpenCode integration, triaged) ### Wiring-review findings (subagent code review of the OpenCode integration, triaged)
Pre-PR fixes (user-approved cut): Pre-PR fixes (user-approved cut):
- [x] **Cancellation does not interrupt the interpreter** - the no-limits rationale claimed - [x] **Cancellation does not interrupt the interpreter** - the no-limits rationale claimed
"user cancel interrupts the execution fiber," but `tools.ts` runs tools via "user cancel interrupts the execution fiber," but `tools.ts` runs tools via
`run.promise` -> `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring; `run.promise` -> `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring;
@@ -1101,7 +1073,6 @@ Pre-PR fixes (user-approved cut):
`title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`. `title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`.
Post-MVP (logged, not blocking an experimental flag): Post-MVP (logged, not blocking an experimental flag):
- [x] **Plugin `tool.execute.before/after` hooks skip child calls** - legacy MCP - [x] **Plugin `tool.execute.before/after` hooks skip child calls** - legacy MCP
registration fires them per tool (`tools.ts:419-441`); under code mode only the registration fires them per tool (`tools.ts:419-441`); under code mode only the
outer `execute` fires them, so auditing/intercepting plugins silently lose MCP outer `execute` fires them, so auditing/intercepting plugins silently lose MCP
@@ -1134,7 +1105,6 @@ Post-MVP (logged, not blocking an experimental flag):
names that are no longer directly callable under code mode. names that are no longer directly callable under code mode.
### Backlog / loose ends (non-blocking, any order) ### Backlog / loose ends (non-blocking, any order)
- [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a - [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a
sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++` sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++`
would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment
@@ -1171,7 +1141,7 @@ Post-MVP (logged, not blocking an experimental flag):
## 5. Context and gotchas for whoever picks this up ## 5. Context and gotchas for whoever picks this up
- **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript, - **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript,
the model wrote `me.result?.login ?? me.result` where the tool result was a JSON _string_ - the model wrote `me.result?.login ?? me.result` where the tool result was a JSON *string* -
the old strict interpreter threw (`String property 'login' is not available`); then the the old strict interpreter threw (`String property 'login' is not available`); then the
model returned a raw 105KB payload, which native truncation dumped to a file, costing a model returned a raw 105KB payload, which native truncation dumped to a file, costing a
subagent round-trip to extract one number. Interpreter forgiveness stops the crashes; subagent round-trip to extract one number. Interpreter forgiveness stops the crashes;
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1,4 +1,10 @@
export { ToolError, CodeMode, ExecuteInputSchema, ExecuteResultSchema, toolError } from "./codemode.js" export {
ToolError,
CodeMode,
ExecuteInputSchema,
ExecuteResultSchema,
toolError,
} from "./codemode.js"
export { Tool } from "./tool.js" export { Tool } from "./tool.js"
export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js" export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js" export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"
+70 -153
View File
@@ -21,16 +21,11 @@ export type HostTools<R = never> = {
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R> export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
? R ? R
: Tools extends { : Tools extends { readonly _tag: "CodeModeTool"; readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R> }
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R ? R
: Tools extends object : Tools extends object
? string extends keyof Tools ? string extends keyof Tools ? never : Services<Tools[keyof Tools]>
? never : never
: Services<Tools[keyof Tools]>
: never
/** Minimal audit record retained for each admitted tool call. */ /** Minimal audit record retained for each admitted tool call. */
export type ToolCall = { export type ToolCall = {
@@ -73,13 +68,11 @@ export type SafeObject = Record<string, unknown>
const reservedNamespace = "$codemode" const reservedNamespace = "$codemode"
const defaultMaxInlineCatalogTokens = 2_000 const defaultMaxInlineCatalogTokens = 2_000
const defaultSearchLimit = 10 const defaultSearchLimit = 10
const searchSignature = const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
"tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
const toolExpression = (path: string) => const toolExpression = (path: string) =>
"tools" + "tools" + path
path
.split(".") .split(".")
.map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)) .map((segment) => identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
.join("") .join("")
export class ToolReference { export class ToolReference {
@@ -95,12 +88,7 @@ const MAX_VALUE_DEPTH = 32
export class ToolRuntimeError extends Error { export class ToolRuntimeError extends Error {
constructor( constructor(
readonly kind: readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded",
| "UnknownTool"
| "InvalidToolInput"
| "InvalidToolOutput"
| "InvalidDataValue"
| "ToolCallLimitExceeded",
message: string, message: string,
readonly suggestions: ReadonlyArray<string> = [], readonly suggestions: ReadonlyArray<string> = [],
) { ) {
@@ -143,13 +131,7 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown => export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
copyBounded(value, label, 0, new Set(), preserveSandboxValues) copyBounded(value, label, 0, new Set(), preserveSandboxValues)
const copyBounded = ( const copyBounded = (value: unknown, label: string, depth: number, seen: Set<object>, preserveSandboxValues: boolean): unknown => {
value: unknown,
label: string,
depth: number,
seen: Set<object>,
preserveSandboxValues: boolean,
): unknown => {
if (depth > MAX_VALUE_DEPTH) { if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
} }
@@ -184,12 +166,7 @@ const copyBounded = (
// Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents
// are never walked here (Map/Set members are validated where mutation happens, and the // are never walked here (Map/Set members are validated where mutation happens, and the
// real boundary still serializes them below). // real boundary still serializes them below).
if ( if (value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet) {
value instanceof SandboxDate ||
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
) {
return value return value
} }
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross // Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
@@ -220,12 +197,8 @@ const copyBounded = (
return Number.isFinite(value.getTime()) ? value.toISOString() : null return Number.isFinite(value.getTime()) ? value.toISOString() : null
} }
if ( if (
value instanceof SandboxRegExp || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet ||
value instanceof SandboxMap || value instanceof RegExp || value instanceof Map || value instanceof Set
value instanceof SandboxSet ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set
) { ) {
return Object.create(null) as SafeObject return Object.create(null) as SafeObject
} }
@@ -277,10 +250,7 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
return value return value
} }
const definitions = <R>( const definitions = <R>(tools: HostTools<R>, path: ReadonlyArray<string> = []): Array<{ path: string; definition: Definition<R> }> => {
tools: HostTools<R>,
path: ReadonlyArray<string> = [],
): Array<{ path: string; definition: Definition<R> }> => {
const entries: Array<{ path: string; definition: Definition<R> }> = [] const entries: Array<{ path: string; definition: Definition<R> }> = []
for (const [name, value] of Object.entries(tools)) { for (const [name, value] of Object.entries(tools)) {
const next = [...path, name] const next = [...path, name]
@@ -372,11 +342,8 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
path, path,
definition.description, definition.description,
...inputProperties(definition).flatMap(({ name, description: property }) => ...inputProperties(definition).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property], property === undefined ? [name] : [name, property]),
), ].join("\n").toLowerCase(),
]
.join("\n")
.toLowerCase(),
}) })
/** The runtime search index over every described tool. Search is always registered. */ /** The runtime search index over every described tool. Search is always registered. */
@@ -429,8 +396,7 @@ export const discoveryPlan = <R>(
namespace, namespace,
picked: new Set<ToolDescription>(), picked: new Set<ToolDescription>(),
queue: [...group].sort( queue: [...group].sort(
(left, right) => (left, right) => estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
), ),
})) }))
let used = 0 let used = 0
@@ -506,9 +472,7 @@ export const discoveryPlan = <R>(
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.", "- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
"- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.", "- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.",
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.", "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
...(complete ...(complete ? [] : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
? []
: ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
] ]
const syntax = [ const syntax = [
@@ -536,21 +500,26 @@ export const discoveryPlan = <R>(
const count = `${group.length} tool${group.length === 1 ? "" : "s"}` const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
// Annotate only when a namespace is not fully shown, so a comprehensive // Annotate only when a namespace is not fully shown, so a comprehensive
// namespace reads cleanly and a truncated one is unambiguous. // namespace reads cleanly and a truncated one is unambiguous.
const label = const label = picked.size === group.length ? count : picked.size === 0 ? `${count}, none shown` : `${count}, ${picked.size} shown`
picked.size === group.length
? count
: picked.size === 0
? `${count}, none shown`
: `${count}, ${picked.size} shown`
toolSection.push(`- ${namespace} (${label})`) toolSection.push(`- ${namespace} (${label})`)
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool)) for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
} }
if (!complete) { if (!complete) {
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`) toolSection.push(
"",
"Search returns complete callable signatures:",
`- ${searchSignature}`,
)
} }
} }
const lines = [...intro, ...workflow, ...rules, ...syntax, ...toolSection] const lines = [
...intro,
...workflow,
...rules,
...syntax,
...toolSection,
]
return { return {
catalog: described, catalog: described,
instructions: lines.join("\n"), instructions: lines.join("\n"),
@@ -565,29 +534,18 @@ export const discoveryPlan = <R>(
* function in JS). An unknown path is an `UnknownTool` error pointing at the working * function in JS). An unknown path is an `UnknownTool` error pointing at the working
* discovery idioms, mirroring how calling an unknown tool fails. * discovery idioms, mirroring how calling an unknown tool fails.
*/ */
const namespaceKeys = <R>( const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): ReadonlyArray<string> => {
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): ReadonlyArray<string> => {
// The reserved discovery namespace is virtual (never present in the host tree); enumerate // The reserved discovery namespace is virtual (never present in the host tree); enumerate
// it explicitly so `Object.keys(tools.$codemode)` matches the callable surface. // it explicitly so `Object.keys(tools.$codemode)` matches the callable surface.
if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"] if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"]
let value: HostTool<R> | Definition<R> | HostTools<R> = tools let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) { for (const segment of path) {
if ( if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
isBlockedMember(segment) ||
typeof value === "function" ||
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError( throw new ToolRuntimeError(
"UnknownTool", "UnknownTool",
`Unknown tool namespace '${path.join(".")}'.`, `Unknown tool namespace '${path.join(".")}'.`,
searchEnabled searchEnabled
? [ ? ["Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools."]
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
]
: ["Object.keys(tools) lists the available namespaces."], : ["Object.keys(tools) lists the available namespaces."],
) )
} }
@@ -597,25 +555,12 @@ const namespaceKeys = <R>(
return Object.keys(value) return Object.keys(value)
} }
const resolve = <R>( const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): HostTool<R> | Definition<R> => {
tools: HostTools<R>,
path: ReadonlyArray<string>,
searchEnabled: boolean,
): HostTool<R> | Definition<R> => {
let value: HostTool<R> | Definition<R> | HostTools<R> = tools let value: HostTool<R> | Definition<R> | HostTools<R> = tools
for (const segment of path) { for (const segment of path) {
if ( if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
isBlockedMember(segment) || throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [])
typeof value === "function" ||
isDefinition(value) ||
!Object.hasOwn(value, segment)
) {
throw new ToolRuntimeError(
"UnknownTool",
`Unknown tool '${path.join(".")}'.`,
searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [],
)
} }
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R> value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
} }
@@ -657,8 +602,7 @@ export const make = <R>(
return effect.pipe( return effect.pipe(
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })), Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
Effect.tapError((error) => Effect.tapError((error) =>
onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) }), onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) })),
),
) )
} }
@@ -680,7 +624,7 @@ export const make = <R>(
calls, calls,
keys: (path) => namespaceKeys(tools, path, searchEnabled), keys: (path) => namespaceKeys(tools, path, searchEnabled),
invoke: (path, args) => invoke: (path, args) =>
Effect.gen(function* () { Effect.gen(function*() {
const name = path.join(".") const name = path.join(".")
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`))) const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
const call = { name } const call = { name }
@@ -693,32 +637,17 @@ export const make = <R>(
if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`) if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`)
const input = externalArgs[0] const input = externalArgs[0]
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) { if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
throw new ToolRuntimeError( throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.")
"InvalidToolInput",
"tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.",
)
} }
const request = input as { query?: unknown; namespace?: unknown; limit?: unknown } const request = input as { query?: unknown; namespace?: unknown; limit?: unknown }
if (request.query !== undefined && typeof request.query !== "string") { if (request.query !== undefined && typeof request.query !== "string") {
throw new ToolRuntimeError( throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search query must be a string when provided.")
"InvalidToolInput",
"tools.$codemode.search query must be a string when provided.",
)
} }
if (request.namespace !== undefined && typeof request.namespace !== "string") { if (request.namespace !== undefined && typeof request.namespace !== "string") {
throw new ToolRuntimeError( throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search namespace must be a string when provided.")
"InvalidToolInput",
"tools.$codemode.search namespace must be a string when provided.",
)
} }
if ( if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)) {
request.limit !== undefined && throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search limit must be a positive safe integer when provided.")
(typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)
) {
throw new ToolRuntimeError(
"InvalidToolInput",
"tools.$codemode.search limit must be a positive safe integer when provided.",
)
} }
const query = typeof request.query === "string" ? request.query : "" const query = typeof request.query === "string" ? request.query : ""
const namespace = typeof request.namespace === "string" ? request.namespace : undefined const namespace = typeof request.namespace === "string" ? request.namespace : undefined
@@ -727,50 +656,40 @@ export const make = <R>(
Effect.try({ Effect.try({
try: () => { try: () => {
const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit
const scoped = const scoped = namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
// A query that names one tool path exactly (canonical path or rendered // A query that names one tool path exactly (canonical path or rendered
// JavaScript expression) is a lookup, not a search: return that tool alone. // JavaScript expression) is a lookup, not a search: return that tool alone.
const trimmed = query.trim() const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact = const exact = pathQuery === "" ? undefined : scoped.find((entry) =>
pathQuery === "" entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed)
? undefined
: scoped.find(
(entry) =>
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const terms = tokenize(query).map(termForms) const terms = tokenize(query).map(termForms)
// Additive field-weighted scoring, summed across terms: exact path or path // Additive field-weighted scoring, summed across terms: exact path or path
// segment (20) > path substring (8) > description substring (4) > any // segment (20) > path substring (8) > description substring (4) > any
// searchable text, incl. input parameter names/descriptions (2). Each term // searchable text, incl. input parameter names/descriptions (2). Each term
// matches a field when any of its forms (the term or a singular variant) // matches a field when any of its forms (the term or a singular variant)
// does. An empty query browses everything, alphabetical by path. // does. An empty query browses everything, alphabetical by path.
const ranked = const ranked = exact !== undefined
exact !== undefined ? [exact]
? [exact] : scoped
: scoped .map((entry) => {
.map((entry) => { const path = entry.description.path.toLowerCase()
const path = entry.description.path.toLowerCase() const description = entry.description.description.toLowerCase()
const description = entry.description.description.toLowerCase() const score = terms.reduce(
const score = terms.reduce( (total, forms) =>
(total, forms) => total +
total + (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + (forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) + (forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) + (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), 0,
0,
)
return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort(
(left, right) =>
right.score - left.score ||
left.entry.description.path.localeCompare(right.entry.description.path),
) )
.map(({ entry }) => entry) return { entry, score }
})
.filter(({ score }) => terms.length === 0 || score > 0)
.sort((left, right) =>
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path))
.map(({ entry }) => entry)
// Result paths are rendered as JavaScript expressions so each `path` is // Result paths are rendered as JavaScript expressions so each `path` is
// directly usable as the call site (`await tools.github.list({ ... })` or // directly usable as the call site (`await tools.github.list({ ... })` or
// `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty, // `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty,
@@ -792,12 +711,10 @@ export const make = <R>(
const tool = resolve(tools, path, searchEnabled) const tool = resolve(tools, path, searchEnabled)
let describedInput: unknown let describedInput: unknown
if (isDefinition(tool)) { if (isDefinition(tool)) {
if (externalArgs.length !== 1) if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
describedInput = yield* Effect.try({ describedInput = yield* Effect.try({
try: () => decodeToolInput(tool, externalArgs[0]), try: () => decodeToolInput(tool, externalArgs[0]),
catch: (cause) => catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
}) })
} }
const input = isDefinition(tool) ? describedInput : externalArgs const input = isDefinition(tool) ? describedInput : externalArgs
@@ -805,7 +722,7 @@ export const make = <R>(
const currentCall = { index, name, input } const currentCall = { index, name, input }
if (isDefinition(tool)) { if (isDefinition(tool)) {
return yield* observeEnd( return yield* observeEnd(
Effect.gen(function* () { Effect.gen(function*() {
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput))) const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
const result = yield* Effect.try({ const result = yield* Effect.try({
try: () => decodeToolOutput(tool, raw), try: () => decodeToolOutput(tool, raw),
@@ -817,7 +734,7 @@ export const make = <R>(
) )
} }
return yield* observeEnd( return yield* observeEnd(
Effect.gen(function* () { Effect.gen(function*() {
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name) return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
}), }),
currentCall, currentCall,
+12 -27
View File
@@ -57,7 +57,8 @@ export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R =
export const isDefinition = <R = never>(value: unknown): value is Definition<R> => export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool" typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool"
const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema) const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top =>
Schema.isSchema(schema)
const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
@@ -68,12 +69,10 @@ const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unkn
export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ /** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name)) const renderKey = (name: string): string => identifierSegment.test(name) ? name : JSON.stringify(name)
const effectNumberSentinel = (schema: JsonSchema) => const effectNumberSentinel = (schema: JsonSchema) =>
schema.type === "string" && schema.type === "string" && Array.isArray(schema.enum) && schema.enum.length === 1 &&
Array.isArray(schema.enum) &&
schema.enum.length === 1 &&
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity") (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
/** /**
@@ -119,8 +118,7 @@ const docTags = (schema: JsonSchema): Array<string> => {
*/ */
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => { const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""), line.replaceAll("*/", "* /").replace(/\s+$/, ""))
)
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return "" if (lines.length === 0) return ""
@@ -129,12 +127,7 @@ const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad
return `${pad}/**\n${body}\n${pad} */\n` return `${pad}/**\n${body}\n${pad} */\n`
} }
const renderSchema = ( const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: ReadonlySet<string> = new Set()): string => {
schema: JsonSchema,
ctx: RenderContext,
depth = 0,
seen: ReadonlySet<string> = new Set(),
): string => {
if (depth > MAX_RENDER_DEPTH) return "unknown" if (depth > MAX_RENDER_DEPTH) return "unknown"
if (schema.$ref) { if (schema.$ref) {
const name = schema.$ref.split("/").pop() const name = schema.$ref.split("/").pop()
@@ -153,16 +146,13 @@ const renderSchema = (
if ( if (
alternatives.some((item) => item.type === "number") && alternatives.some((item) => item.type === "number") &&
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
) ) return "number"
return "number"
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`. // (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
if ( if (
alternatives.length === 2 && alternatives.length === 2 &&
alternatives[0]?.type === "object" && alternatives[0]?.type === "object" && alternatives[0].properties === undefined &&
alternatives[0].properties === undefined && alternatives[1]?.type === "array" && alternatives[1].items === undefined
alternatives[1]?.type === "array" &&
alternatives[1].items === undefined
) { ) {
return "{}" return "{}"
} }
@@ -180,8 +170,7 @@ const renderSchema = (
const required = new Set(schema.required ?? []) const required = new Set(schema.required ?? [])
const properties = Object.entries(schema.properties ?? {}) const properties = Object.entries(schema.properties ?? {})
const additional = schema.additionalProperties const additional = schema.additionalProperties
const indexType = const indexType = additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
const field = ([name, value]: readonly [string, JsonSchema]) => const field = ([name, value]: readonly [string, JsonSchema]) =>
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}` `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}`
@@ -194,9 +183,7 @@ const renderSchema = (
// Pretty: an indented block, each described field preceded by its JSDoc comment. // Pretty: an indented block, each described field preceded by its JSDoc comment.
if (properties.length === 0 && indexType === undefined) return "{}" if (properties.length === 0 && indexType === undefined) return "{}"
const pad = " ".repeat(depth + 1) const pad = " ".repeat(depth + 1)
const lines = properties.map( const lines = properties.map((entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`)
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`,
)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`) if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
} }
@@ -275,9 +262,7 @@ export const inputProperties = <R>(definition: Definition<R>): Array<InputProper
* fields; the default stays the compact single-line form. * fields; the default stays the compact single-line form.
*/ */
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string => export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
isEffectSchema(definition.input) isEffectSchema(definition.input) ? toTypeScript(definition.input, false, pretty) : jsonSchemaToTypeScript(definition.input, pretty)
? toTypeScript(definition.input, false, pretty)
: jsonSchemaToTypeScript(definition.input, pretty)
/** /**
* The model-visible TypeScript type of a tool's result; tools without an output schema * The model-visible TypeScript type of a tool's result; tools without an output schema
+1 -4
View File
@@ -28,7 +28,4 @@ export class SandboxSet {
} }
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet => export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
value instanceof SandboxDate || value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet
value instanceof SandboxRegExp ||
value instanceof SandboxMap ||
value instanceof SandboxSet
+172 -267
View File
@@ -1,13 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect" import { Cause, Effect, Schema } from "effect"
import { import { CodeMode, ExecuteInputSchema, ExecuteResultSchema, Tool, toolError, type ExecutionLimits } from "../src/index.js"
CodeMode,
ExecuteInputSchema,
ExecuteResultSchema,
Tool,
toolError,
type ExecutionLimits,
} from "../src/index.js"
import type { Definition } from "../src/tool.js" import type { Definition } from "../src/tool.js"
const run = (tool: Definition<never>) => const run = (tool: Definition<never>) =>
@@ -82,14 +75,11 @@ describe("CodeMode host failure boundary", () => {
output: Schema.Unknown, output: Schema.Unknown,
run: () => run: () =>
Effect.succeed( Effect.succeed(
new Proxy( new Proxy({}, {
{}, ownKeys: () => {
{ throw new Error("host-output-secret")
ownKeys: () => {
throw new Error("host-output-secret")
},
}, },
), }),
), ),
}), }),
) )
@@ -172,7 +162,9 @@ describe("CodeMode tool-call observation", () => {
) )
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }]) expect(calls).toStrictEqual([
{ index: 0, name: "context.lookup", input: { query: "deployment failure" } },
])
}) })
test("observes settled calls with outcome and duration", async () => { test("observes settled calls with outcome and duration", async () => {
@@ -181,26 +173,25 @@ describe("CodeMode tool-call observation", () => {
description: "Look up a value", description: "Look up a value",
input: Schema.Struct({ query: Schema.String }), input: Schema.Struct({ query: Schema.String }),
output: Schema.String, output: Schema.String,
run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)), run: ({ query }) =>
query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query),
}) })
const runtime = CodeMode.make({ const runtime = CodeMode.make({
tools: { context: { lookup } }, tools: { context: { lookup } },
onToolCallStart: (call) => onToolCallStart: (call) => Effect.sync(() => {
Effect.sync(() => { events.push({ phase: "start", index: call.index, name: call.name })
events.push({ phase: "start", index: call.index, name: call.name }) }),
}), onToolCallEnd: (call) => Effect.sync(() => {
onToolCallEnd: (call) => expect(call.durationMs).toBeGreaterThanOrEqual(0)
Effect.sync(() => { events.push({
expect(call.durationMs).toBeGreaterThanOrEqual(0) phase: "end",
events.push({ index: call.index,
phase: "end", name: call.name,
index: call.index, outcome: call.outcome,
name: call.name, ...(call.message === undefined ? {} : { message: call.message }),
outcome: call.outcome, })
...(call.message === undefined ? {} : { message: call.message }), }),
})
}),
}) })
const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`)) const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
@@ -219,15 +210,13 @@ describe("CodeMode tool-call observation", () => {
describe("CodeMode console capture", () => { describe("CodeMode console capture", () => {
test("captures console output as bounded result logs", async () => { test("captures console output as bounded result logs", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `
code: `
const returned = console.log("Thread info:", { name: "Demo", count: 2 }) const returned = console.log("Thread info:", { name: "Demo", count: 2 })
console.warn("careful") console.warn("careful")
return returned return returned
`, `,
}), }))
)
expect(result).toStrictEqual({ expect(result).toStrictEqual({
ok: true, ok: true,
@@ -239,45 +228,39 @@ describe("CodeMode console capture", () => {
}) })
test("keeps logs captured before failures", async () => { test("keeps logs captured before failures", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `
code: `
console.log("before failure") console.log("before failure")
throw new Error("boom") throw new Error("boom")
`, `,
}), }))
)
expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"]) expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"])
expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom") expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom")
}) })
test("prints NaN and Infinity literally instead of the JSON null", async () => { test("prints NaN and Infinity literally instead of the JSON null", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `
code: `
console.log(NaN) console.log(NaN)
console.log(Infinity, -Infinity) console.log(Infinity, -Infinity)
console.log({ ratio: NaN, bounds: [Infinity] }) console.log({ ratio: NaN, bounds: [Infinity] })
return null return null
`, `,
}), }))
)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}']) expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}'])
}) })
test("renders sandbox values nested inside logged containers", async () => { test("renders sandbox values nested inside logged containers", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `
code: `
console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) }) console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) })
console.log([new Date(0)]) console.log([new Date(0)])
return null return null
`, `,
}), }))
)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual([ expect(result.logs).toStrictEqual([
@@ -287,40 +270,38 @@ describe("CodeMode console capture", () => {
}) })
test("console formatting is total: cycles and opaque references render as markers", async () => { test("console formatting is total: cycles and opaque references render as markers", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `
code: `
const m = new Map() const m = new Map()
m.set("self", m) m.set("self", m)
console.log({ box: m }) console.log({ box: m })
console.log({ fn: (x) => x, ok: 1 }) console.log({ fn: (x) => x, ok: 1 })
return null return null
`, `,
}), }))
)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual(['{"box":Map(1) [["self",[Circular]]]}', '{"fn":[CodeMode reference],"ok":1}']) expect(result.logs).toStrictEqual([
'{"box":Map(1) [["self",[Circular]]]}',
'{"fn":[CodeMode reference],"ok":1}',
])
}) })
test("console.table renders sandbox value cells", async () => { test("console.table renders sandbox value cells", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `
code: `
console.table([{ when: new Date(0), n: NaN }]) console.table([{ when: new Date(0), n: NaN }])
return null return null
`, `,
}), }))
)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"]) expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"])
}) })
test("captures console.dir and console.table output", async () => { test("captures console.dir and console.table output", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `
code: `
console.dir({ nested: { ok: true } }) console.dir({ nested: { ok: true } })
console.table([ console.table([
{ name: "Kit", count: 1, hidden: "x" }, { name: "Kit", count: 1, hidden: "x" },
@@ -328,13 +309,15 @@ describe("CodeMode console capture", () => {
], ["name", "count"]) ], ["name", "count"])
return "done" return "done"
`, `,
}), }))
)
expect(result).toStrictEqual({ expect(result).toStrictEqual({
ok: true, ok: true,
value: "done", value: "done",
logs: ['{"nested":{"ok":true}}', "(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2"], logs: [
'{"nested":{"ok":true}}',
"(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2",
],
toolCalls: [], toolCalls: [],
}) })
}) })
@@ -342,11 +325,9 @@ describe("CodeMode console capture", () => {
describe("CodeMode output budget", () => { describe("CodeMode output budget", () => {
test("absent maxOutputBytes means no truncation at all", async () => { test("absent maxOutputBytes means no truncation at all", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`,
code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`, }))
}),
)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
if (!result.ok) return if (!result.ok) return
@@ -357,35 +338,29 @@ describe("CodeMode output budget", () => {
test("truncates an oversized result value with a marker instead of failing", async () => { test("truncates an oversized result value with a marker instead of failing", async () => {
const limits: ExecutionLimits = { maxOutputBytes: 40 } const limits: ExecutionLimits = { maxOutputBytes: 40 }
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `return { data: "${"x".repeat(200)}" }`,
code: `return { data: "${"x".repeat(200)}" }`, limits,
limits, }))
}),
)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
if (!result.ok) return if (!result.ok) return
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(typeof result.value).toBe("string") expect(typeof result.value).toBe("string")
expect(result.value).toMatch( expect(result.value).toMatch(/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/)
/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/,
)
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
}) })
test("keeps leading logs within the remaining budget and marks the cut", async () => { test("keeps leading logs within the remaining budget and marks the cut", async () => {
const limits: ExecutionLimits = { maxOutputBytes: 40 } const limits: ExecutionLimits = { maxOutputBytes: 40 }
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `
code: `
console.log("first line") console.log("first line")
console.log("${"y".repeat(200)}") console.log("${"y".repeat(200)}")
return "ok" return "ok"
`, `,
limits, limits,
}), }))
)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
if (!result.ok) return if (!result.ok) return
@@ -395,14 +370,12 @@ describe("CodeMode output budget", () => {
}) })
test("does not mark results within the budget", async () => { test("does not mark results within the budget", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `
code: `
console.log("fits") console.log("fits")
return { fits: true } return { fits: true }
`, `,
}), }))
)
expect(result).toStrictEqual({ expect(result).toStrictEqual({
ok: true, ok: true,
value: { fits: true }, value: { fits: true },
@@ -422,21 +395,18 @@ describe("CodeMode schema flexibility", () => {
properties: { id: { type: "string" }, count: { type: "number" } }, properties: { id: { type: "string" }, count: { type: "number" } },
required: ["id"], required: ["id"],
}, },
run: (input) => run: (input) => Effect.sync(() => {
Effect.sync(() => { observed.push(input)
observed.push(input) return { echoed: input }
return { echoed: input } }),
}),
}) })
const runtime = CodeMode.make({ tools: { adapter: { call } } }) const runtime = CodeMode.make({ tools: { adapter: { call } } })
expect(runtime.catalog()).toStrictEqual([ expect(runtime.catalog()).toStrictEqual([{
{ path: "adapter.call",
path: "adapter.call", description: "Call an adapter-described tool",
description: "Call an adapter-described tool", signature: "tools.adapter.call(input: { id: string; count?: number }): Promise<unknown>",
signature: "tools.adapter.call(input: { id: string; count?: number }): Promise<unknown>", }])
},
])
// JSON Schema is render-only: mistyped input passes through unvalidated. // JSON Schema is render-only: mistyped input passes through unvalidated.
const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`)) const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`))
@@ -451,25 +421,17 @@ describe("CodeMode schema flexibility", () => {
input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] }, input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] },
output: { output: {
$ref: "#/$defs/User", $ref: "#/$defs/User",
$defs: { $defs: { User: { type: "object", properties: { login: { type: "string" }, id: { type: "number" } }, required: ["login", "id"] } },
User: {
type: "object",
properties: { login: { type: "string" }, id: { type: "number" } },
required: ["login", "id"],
},
},
}, },
run: () => Effect.succeed({ login: "kit", id: 7 }), run: () => Effect.succeed({ login: "kit", id: 7 }),
}) })
const runtime = CodeMode.make({ tools: { users: { lookup } } }) const runtime = CodeMode.make({ tools: { users: { lookup } } })
expect(runtime.catalog()).toStrictEqual([ expect(runtime.catalog()).toStrictEqual([{
{ path: "users.lookup",
path: "users.lookup", description: "Look up a user",
description: "Look up a user", signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>",
signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>", }])
},
])
const result = await Effect.runPromise(runtime.execute(`return await tools.users.lookup({ login: "kit" })`)) const result = await Effect.runPromise(runtime.execute(`return await tools.users.lookup({ login: "kit" })`))
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
@@ -516,20 +478,16 @@ describe("CodeMode public contract", () => {
expect(agentTool.input).toBe(ExecuteInputSchema) expect(agentTool.input).toBe(ExecuteInputSchema)
expect(agentTool.output).toBe(ExecuteResultSchema) expect(agentTool.output).toBe(ExecuteResultSchema)
expect(agentTool.description).toBe(runtime.instructions()) expect(agentTool.description).toBe(runtime.instructions())
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual( expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(projected)
projected,
)
}) })
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => { test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
const runtime = CodeMode.make({ tools }) const runtime = CodeMode.make({ tools })
expect(runtime.catalog()).toStrictEqual([ expect(runtime.catalog()).toStrictEqual([{
{ path: "orders.lookup",
path: "orders.lookup", description: "Look up an order by ID",
description: "Look up an order by ID", signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>",
signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>", }])
},
])
expect(runtime.instructions()).toContain("Available tools (COMPLETE list") expect(runtime.instructions()).toContain("Available tools (COMPLETE list")
expect(runtime.instructions()).toContain("- orders (1 tool)") expect(runtime.instructions()).toContain("- orders (1 tool)")
expect(runtime.instructions()).toContain( expect(runtime.instructions()).toContain(
@@ -544,13 +502,11 @@ describe("CodeMode public contract", () => {
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
if (result.ok) { if (result.ok) {
expect(result.value).toStrictEqual({ expect(result.value).toStrictEqual({
items: [ items: [{
{ path: "tools.orders.lookup",
path: "tools.orders.lookup", description: "Look up an order by ID",
description: "Look up an order by ID", signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>",
signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>", }],
},
],
total: 1, total: 1,
}) })
} }
@@ -565,43 +521,31 @@ describe("CodeMode public contract", () => {
}) })
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
expect(runtime.catalog()).toStrictEqual([ expect(runtime.catalog()).toStrictEqual([{
{ path: "context7.resolve-library-id",
path: "context7.resolve-library-id", description: "Resolve a library ID",
description: "Resolve a library ID", signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>', }])
}, expect(runtime.instructions()).toContain('tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>')
])
expect(runtime.instructions()).toContain(
'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise<string>',
)
const search = await Effect.runPromise( const search = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`))
runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`),
)
expect(search.ok).toBe(true) expect(search.ok).toBe(true)
if (search.ok) { if (search.ok) {
expect(search.value).toStrictEqual({ expect(search.value).toStrictEqual({
items: [ items: [{
{ path: 'tools.context7["resolve-library-id"]',
path: 'tools.context7["resolve-library-id"]', description: "Resolve a library ID",
description: "Resolve a library ID", signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise<string>',
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise<string>', }],
},
],
total: 1, total: 1,
}) })
} }
const call = await Effect.runPromise( const call = await Effect.runPromise(runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`))
runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`),
)
expect(call.ok).toBe(true) expect(call.ok).toBe(true)
if (call.ok) expect(call.value).toBe("/resolved/TypeScript") if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
const exact = await Effect.runPromise( const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`))
runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`),
)
expect(exact.ok).toBe(true) expect(exact.ok).toBe(true)
if (exact.ok) expect((exact.value as { total: number }).total).toBe(1) if (exact.ok) expect((exact.value as { total: number }).total).toBe(1)
}) })
@@ -617,9 +561,7 @@ describe("CodeMode public contract", () => {
expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax")) expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax"))
expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list")) expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list"))
// The workflow carries the result-shape guidance; Rules only add content beyond it. // The workflow carries the result-shape guidance; Rules only add content beyond it.
expect(instructions).toContain( expect(instructions).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string')
'`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
)
expect(instructions).toContain("Return only the fields you need") expect(instructions).toContain("Return only the fields you need")
expect(instructions).toContain("raw payloads get truncated and waste context") expect(instructions).toContain("raw payloads get truncated and waste context")
expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`") expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`")
@@ -642,12 +584,8 @@ describe("CodeMode public contract", () => {
expect(partial).toContain( expect(partial).toContain(
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.', '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
) )
expect(partial).toContain( expect(partial).toContain("Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`")
"Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`", expect(partial).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.')
)
expect(partial).toContain(
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
)
expect(partial).not.toContain("total_count") expect(partial).not.toContain("total_count")
expect(partial).not.toContain("tools.orders.lookup({") expect(partial).not.toContain("tools.orders.lookup({")
}) })
@@ -666,9 +604,7 @@ describe("CodeMode public contract", () => {
expect(instructions).not.toContain("instanceof Error") expect(instructions).not.toContain("instanceof Error")
expect(instructions).not.toContain("splice") expect(instructions).not.toContain("splice")
// The data-boundary note survives. // The data-boundary note survives.
expect(instructions).toContain( expect(instructions).toContain("Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.")
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
)
}) })
test("zero tools keep minimal sections and the no-tools notice", () => { test("zero tools keep minimal sections and the no-tools notice", () => {
@@ -699,22 +635,18 @@ describe("CodeMode public contract", () => {
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
discovery: { maxInlineCatalogTokens: 0 }, discovery: { maxInlineCatalogTokens: 0 },
}) })
expect(runtime.instructions()).toContain( expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)")
"Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)",
)
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)") expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)") expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
expect(runtime.instructions()).toMatch(/\$codemode\.search/) expect(runtime.instructions()).toMatch(/\$codemode\.search/)
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/) expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
const result = await Effect.runPromise( const result = await Effect.runPromise(runtime.execute(`
runtime.execute(`
return await tools.$codemode.search({ return await tools.$codemode.search({
query: "send message attachment upload file to current Discord thread", query: "send message attachment upload file to current Discord thread",
limit: 2 limit: 2
}) })
`), `))
)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
if (!result.ok) return if (!result.ok) return
expect(result.value).toStrictEqual({ expect(result.value).toStrictEqual({
@@ -734,27 +666,19 @@ describe("CodeMode public contract", () => {
}) })
expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }]) expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }])
const variants = await Effect.runPromise( const variants = await Effect.runPromise(runtime.execute(`
runtime.execute(`
return await Promise.all([ return await Promise.all([
tools.$codemode.search({ query: "file" }), tools.$codemode.search({ query: "file" }),
tools.$codemode.search({ query: "image" }) tools.$codemode.search({ query: "image" })
]) ])
`), `))
)
expect(variants.ok).toBe(true) expect(variants.ok).toBe(true)
if (variants.ok) { if (variants.ok) {
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe( expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe("tools.thread.uploadFile")
"tools.thread.uploadFile", expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe("tools.thread.generateImage")
)
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe(
"tools.thread.generateImage",
)
} }
const removed = await Effect.runPromise( const removed = await Effect.runPromise(runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`))
runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`),
)
expect(removed.ok).toBe(false) expect(removed.ok).toBe(false)
if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool") if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool")
}) })
@@ -782,19 +706,15 @@ describe("CodeMode public contract", () => {
} }
for (const query of ["many.tool13", "tools.many.tool13"]) { for (const query of ["many.tool13", "tools.many.tool13"]) {
const exact = await Effect.runPromise( const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`))
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
)
expect(exact.ok).toBe(true) expect(exact.ok).toBe(true)
if (exact.ok) { if (exact.ok) {
expect(exact.value).toStrictEqual({ expect(exact.value).toStrictEqual({
items: [ items: [{
{ path: "tools.many.tool13",
path: "tools.many.tool13", description: "Numbered tool 13",
description: "Numbered tool 13", signature: "tools.many.tool13(input: {\n id: string\n}): Promise<string>",
signature: "tools.many.tool13(input: {\n id: string\n}): Promise<string>", }],
},
],
total: 1, total: 1,
}) })
} }
@@ -817,23 +737,20 @@ describe("CodeMode public contract", () => {
}) })
// Empty query + namespace browses just that namespace, alphabetical by path. // Empty query + namespace browses just that namespace, alphabetical by path.
const browse = await Effect.runPromise( const browse = await Effect.runPromise(runtime.execute(
runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`), `return await tools.$codemode.search({ query: "", namespace: "github" })`,
) ))
expect(browse.ok).toBe(true) expect(browse.ok).toBe(true)
if (browse.ok) { if (browse.ok) {
const value = browse.value as { items: Array<{ path: string }>; total: number } const value = browse.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(2) expect(value.total).toBe(2)
expect(value.items.map((item) => item.path)).toStrictEqual([ expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.create_issue", "tools.github.list_issues"])
"tools.github.create_issue",
"tools.github.list_issues",
])
} }
// A query + namespace ranks within that namespace only. // A query + namespace ranks within that namespace only.
const scoped = await Effect.runPromise( const scoped = await Effect.runPromise(runtime.execute(
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`), `return await tools.$codemode.search({ query: "issues", namespace: "linear" })`,
) ))
expect(scoped.ok).toBe(true) expect(scoped.ok).toBe(true)
if (scoped.ok) { if (scoped.ok) {
const value = scoped.value as { items: Array<{ path: string }>; total: number } const value = scoped.value as { items: Array<{ path: string }>; total: number }
@@ -841,9 +758,9 @@ describe("CodeMode public contract", () => {
expect(value.items[0]?.path).toBe("tools.linear.list_issues") expect(value.items[0]?.path).toBe("tools.linear.list_issues")
} }
const invalid = await Effect.runPromise( const invalid = await Effect.runPromise(runtime.execute(
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`), `return await tools.$codemode.search({ query: "issues", namespace: 7 })`,
) ))
expect(invalid.ok).toBe(false) expect(invalid.ok).toBe(false)
if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput") if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput")
}) })
@@ -868,9 +785,9 @@ describe("CodeMode public contract", () => {
// "attachment" appears in neither path nor description - only in the input schema's // "attachment" appears in neither path nor description - only in the input schema's
// property names, which the searchable text includes. // property names, which the searchable text includes.
const byParameter = await Effect.runPromise( const byParameter = await Effect.runPromise(runtime.execute(
runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`), `return await tools.$codemode.search({ query: "attachment" })`,
) ))
expect(byParameter.ok).toBe(true) expect(byParameter.ok).toBe(true)
if (byParameter.ok) { if (byParameter.ok) {
const value = byParameter.value as { items: Array<{ path: string }>; total: number } const value = byParameter.value as { items: Array<{ path: string }>; total: number }
@@ -879,9 +796,9 @@ describe("CodeMode public contract", () => {
} }
// Substring matching: a partial word ("docum") still hits the description. // Substring matching: a partial word ("docum") still hits the description.
const bySubstring = await Effect.runPromise( const bySubstring = await Effect.runPromise(runtime.execute(
runtime.execute(`return await tools.$codemode.search({ query: "docum" })`), `return await tools.$codemode.search({ query: "docum" })`,
) ))
expect(bySubstring.ok).toBe(true) expect(bySubstring.ok).toBe(true)
if (bySubstring.ok) { if (bySubstring.ok) {
const value = bySubstring.value as { items: Array<{ path: string }>; total: number } const value = bySubstring.value as { items: Array<{ path: string }>; total: number }
@@ -908,9 +825,9 @@ describe("CodeMode public contract", () => {
}) })
// "issues" still finds the singular-only tool (term OR singular(term) per field)... // "issues" still finds the singular-only tool (term OR singular(term) per field)...
const plural = await Effect.runPromise( const plural = await Effect.runPromise(runtime.execute(
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`), `return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`,
) ))
expect(plural.ok).toBe(true) expect(plural.ok).toBe(true)
if (plural.ok) { if (plural.ok) {
const value = plural.value as { items: Array<{ path: string }>; total: number } const value = plural.value as { items: Array<{ path: string }>; total: number }
@@ -919,15 +836,14 @@ describe("CodeMode public contract", () => {
} }
// ...while a true "issues" path match still outranks the singular-only description match. // ...while a true "issues" path match still outranks the singular-only description match.
const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`)) const ranked = await Effect.runPromise(runtime.execute(
`return await tools.$codemode.search({ query: "issues" })`,
))
expect(ranked.ok).toBe(true) expect(ranked.ok).toBe(true)
if (ranked.ok) { if (ranked.ok) {
const value = ranked.value as { items: Array<{ path: string }>; total: number } const value = ranked.value as { items: Array<{ path: string }>; total: number }
expect(value.total).toBe(2) expect(value.total).toBe(2)
expect(value.items.map((item) => item.path)).toStrictEqual([ expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.list_issues", "tools.tracker.fetch_all"])
"tools.github.list_issues",
"tools.tracker.fetch_all",
])
} }
}) })
@@ -966,12 +882,8 @@ describe("CodeMode public contract", () => {
run: () => Effect.succeed("ok"), run: () => Effect.succeed("ok"),
}) })
const expensive = Tool.make({ const expensive = Tool.make({
description: description: "An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime",
"An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime", input: Schema.Struct({ someRatherLongParameterName: Schema.String, anotherEvenLongerParameterName: Schema.Number }),
input: Schema.Struct({
someRatherLongParameterName: Schema.String,
anotherEvenLongerParameterName: Schema.Number,
}),
output: Schema.String, output: Schema.String,
run: () => Effect.succeed("ok"), run: () => Effect.succeed("ok"),
}) })
@@ -984,9 +896,7 @@ describe("CodeMode public contract", () => {
}) })
const instructions = runtime.instructions() const instructions = runtime.instructions()
expect(instructions).toContain( expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)")
"Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)",
)
expect(instructions).toContain("- alpha (2 tools, 1 shown)") expect(instructions).toContain("- alpha (2 tools, 1 shown)")
expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise<string> // Cheap") expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise<string> // Cheap")
expect(instructions).not.toContain("tools.alpha.expensive(") expect(instructions).not.toContain("tools.alpha.expensive(")
@@ -1002,11 +912,10 @@ describe("CodeMode public contract", () => {
description: "Double a number", description: "Double a number",
input: Schema.Struct({ value: Schema.NumberFromString }), input: Schema.Struct({ value: Schema.NumberFromString }),
output: Schema.NumberFromString, output: Schema.NumberFromString,
run: ({ value }) => run: ({ value }) => Effect.sync(() => {
Effect.sync(() => { observed.push(value)
observed.push(value) return String(value * 2)
return String(value * 2) }),
}),
}) })
const runtime = CodeMode.make({ const runtime = CodeMode.make({
tools: { math: { double: transformed } }, tools: { math: { double: transformed } },
@@ -1025,11 +934,9 @@ describe("CodeMode public contract", () => {
}) })
test("returns JSON-safe data and normalizes undefined to null", async () => { test("returns JSON-safe data and normalizes undefined to null", async () => {
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ code: `return { top: undefined, nested: [1, undefined] }`,
code: `return { top: undefined, nested: [1, undefined] }`, }))
}),
)
expect(result).toStrictEqual({ expect(result).toStrictEqual({
ok: true, ok: true,
value: { top: null, nested: [1, null] }, value: { top: null, nested: [1, null] },
@@ -1040,20 +947,18 @@ describe("CodeMode public contract", () => {
test("rejects invalid configuration and discovery limits", async () => { test("rejects invalid configuration and discovery limits", async () => {
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow( expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(RangeError)
RangeError,
)
expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError)
expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError)
expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError) expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError)
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.make({
CodeMode.make({ tools,
tools, discovery: { maxInlineCatalogTokens: 0 },
discovery: { maxInlineCatalogTokens: 0 }, }).execute(
}).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`), `return await tools.$codemode.search({ query: "order", limit: 0.5 })`,
) ))
expect(result.ok).toBe(false) expect(result.ok).toBe(false)
if (result.ok) return if (result.ok) return
expect(result.error.kind).toBe("InvalidToolInput") expect(result.error.kind).toBe("InvalidToolInput")
@@ -1074,16 +979,14 @@ describe("CodeMode public contract", () => {
output: Schema.Number, output: Schema.Number,
run: () => Effect.succeed(1), run: () => Effect.succeed(1),
}) })
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ tools: { host: { count: counter } },
tools: { host: { count: counter } }, code: `
code: `
let total = 0 let total = 0
for (let i = 0; i < 150; i += 1) total += await tools.host.count({}) for (let i = 0; i < 150; i += 1) total += await tools.host.count({})
return total return total
`, `,
}), }))
)
expect(result).toMatchObject({ ok: true, value: 150 }) expect(result).toMatchObject({ ok: true, value: 150 })
if (result.ok) expect(result.toolCalls.length).toBe(150) if (result.ok) expect(result.toolCalls.length).toBe(150)
}) })
@@ -1105,6 +1008,8 @@ describe("CodeMode public contract", () => {
}) })
test("reserves the discovery namespace", () => { test("reserves the discovery namespace", () => {
expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/) expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(
/reserved for CodeMode discovery tools/,
)
}) })
}) })
+12 -24
View File
@@ -36,12 +36,10 @@ const error = async (code: string) => {
describe("Object.keys over tool references", () => { describe("Object.keys over tool references", () => {
test("enumerates top-level namespaces (the transcript program)", async () => { test("enumerates top-level namespaces (the transcript program)", async () => {
expect( expect(await value(`
await value(`
const namespaces = Object.keys(tools) const namespaces = Object.keys(tools)
return { namespaces, count: namespaces.length } return { namespaces, count: namespaces.length }
`), `)).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
}) })
test("enumerates tool names at a nested namespace", async () => { test("enumerates tool names at a nested namespace", async () => {
@@ -94,8 +92,7 @@ describe("Object.keys over arrays", () => {
describe("for...in", () => { describe("for...in", () => {
test("iterates own enumerable keys of a plain object with break/continue", async () => { test("iterates own enumerable keys of a plain object with break/continue", async () => {
expect( expect(await value(`
await value(`
const seen = [] const seen = []
for (const key in { a: 1, b: 2, c: 3, d: 4 }) { for (const key in { a: 1, b: 2, c: 3, d: 4 }) {
if (key === "b") continue if (key === "b") continue
@@ -103,50 +100,41 @@ describe("for...in", () => {
seen.push(key) seen.push(key)
} }
return seen return seen
`), `)).toEqual(["a", "c"])
).toEqual(["a", "c"])
}) })
test("iterates index strings over arrays", async () => { test("iterates index strings over arrays", async () => {
expect( expect(await value(`
await value(`
const indexes = [] const indexes = []
for (const i in ["x", "y", "z"]) { for (const i in ["x", "y", "z"]) {
if (i === "2") break if (i === "2") break
indexes.push(i) indexes.push(i)
} }
return indexes return indexes
`), `)).toEqual(["0", "1"])
).toEqual(["0", "1"])
}) })
test("supports let declarations and bare identifiers", async () => { test("supports let declarations and bare identifiers", async () => {
expect( expect(await value(`
await value(`
let last = "" let last = ""
for (let key in { a: 1, b: 2 }) last = key for (let key in { a: 1, b: 2 }) last = key
return last return last
`), `)).toBe("b")
).toBe("b") expect(await value(`
expect(
await value(`
let key = "before" let key = "before"
for (key in { only: 1 }) {} for (key in { only: 1 }) {}
return key return key
`), `)).toBe("only")
).toBe("only")
}) })
test("enumerates namespaces and tools from the host tool tree", async () => { test("enumerates namespaces and tools from the host tool tree", async () => {
expect( expect(await value(`
await value(`
const names = [] const names = []
for (const ns in tools) { for (const ns in tools) {
for (const name in tools[ns]) names.push(ns + "." + name) for (const name in tools[ns]) names.push(ns + "." + name)
} }
return names return names
`), `)).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
}) })
test("unsupported values fail with a hint at for...of and Object.keys", async () => { test("unsupported values fail with a hint at for...of and Object.keys", async () => {
+52 -97
View File
@@ -143,40 +143,21 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
describe("Error values and instanceof", () => { describe("Error values and instanceof", () => {
test("new Error carries name/message and is instanceof Error", async () => { test("new Error carries name/message and is instanceof Error", async () => {
expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([ expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "boom"])
true,
"Error",
"boom",
])
}) })
test("Error without new behaves like new Error", async () => { test("Error without new behaves like new Error", async () => {
expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([ expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "plain"])
true, expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual(["Error", "", true])
"Error",
"plain",
])
expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([
"Error",
"",
true,
])
}) })
test("specific error types are instanceof themselves and Error, not each other", async () => { test("specific error types are instanceof themselves and Error, not each other", async () => {
expect( expect(await value(`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`)).toEqual([true, true, false])
await value(
`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`,
),
).toEqual([true, true, false])
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false) expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
}) })
test("thrown errors keep instanceof through try/catch", async () => { test("thrown errors keep instanceof through try/catch", async () => {
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([ expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([true, "x"])
true,
"x",
])
}) })
test("interpreter runtime failures are caught as Error values", async () => { test("interpreter runtime failures are caught as Error values", async () => {
@@ -187,48 +168,33 @@ describe("Error values and instanceof", () => {
test("caught failures carry the constructor name the real-JS failure would have", async () => { test("caught failures carry the constructor name the real-JS failure would have", async () => {
// JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the // JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the
// message keeps the engine's position detail. // message keeps the engine's position detail.
expect( expect(await value(`
await value(`
try { JSON.parse("{oops") } catch (e) { try { JSON.parse("{oops") } catch (e) {
return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")] return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")]
} }
`), `)).toEqual(["SyntaxError", true, true, false, true])
).toEqual(["SyntaxError", true, true, false, true]) expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`))
expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([ .toEqual(["ReferenceError", true])
"ReferenceError", expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`))
true, .toEqual(["TypeError", true])
]) expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`))
expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([ .toEqual(["RangeError", true])
"TypeError", expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`))
true, .toEqual(["SyntaxError", true])
]) expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`))
expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual( .toEqual(["SyntaxError", true])
["RangeError", true],
)
expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
"SyntaxError",
true,
])
expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)).toEqual([
"SyntaxError",
true,
])
}) })
test("diagnostics without a specific real-JS analogue are named plain Error", async () => { test("diagnostics without a specific real-JS analogue are named plain Error", async () => {
expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([ expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`))
"Error", .toEqual(["Error", true])
true,
])
}) })
test("Promise.allSettled rejection reasons are Error values", async () => { test("Promise.allSettled rejection reasons are Error values", async () => {
expect( expect(await value(`
await value(`
const settled = await Promise.allSettled([Promise.reject(new Error("b"))]) const settled = await Promise.allSettled([Promise.reject(new Error("b"))])
return [settled[0].reason instanceof Error, settled[0].reason.message] return [settled[0].reason instanceof Error, settled[0].reason.message]
`), `)).toEqual([true, "b"])
).toEqual([true, "b"])
}) })
test("non-error thrown values are not instanceof Error", async () => { test("non-error thrown values are not instanceof Error", async () => {
@@ -237,11 +203,7 @@ describe("Error values and instanceof", () => {
}) })
test("plain data is never instanceof Error", async () => { test("plain data is never instanceof Error", async () => {
expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([ expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([false, false, false])
false,
false,
false,
])
}) })
test("error values still serialize as plain { name, message } data", async () => { test("error values still serialize as plain { name, message } data", async () => {
@@ -265,29 +227,17 @@ describe("Error values and instanceof", () => {
describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
test("splice removes in place and returns the removed elements", async () => { test("splice removes in place and returns the removed elements", async () => {
expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1, 4] })
removed: [2, 3],
a: [1, 4],
})
}) })
test("splice inserts new elements at the cut", async () => { test("splice inserts new elements at the cut", async () => {
expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"]) expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"])
expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ removed: [2], a: [1, "x", 3] })
removed: [2],
a: [1, "x", 3],
})
}) })
test("splice with one argument removes to the end; negative start counts back", async () => { test("splice with one argument removes to the end; negative start counts back", async () => {
expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1] })
removed: [2, 3], expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({ removed: [3], a: [1, 2] })
a: [1],
})
expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({
removed: [3],
a: [1, 2],
})
}) })
test("splice rejects inserting a container into itself", async () => { test("splice rejects inserting a container into itself", async () => {
@@ -308,13 +258,11 @@ describe("array methods: splice, fill, copyWithin, keys/values/entries", () => {
test("keys/values/entries return arrays usable with for...of and spread", async () => { test("keys/values/entries return arrays usable with for...of and spread", async () => {
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2]) expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"]) expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"])
expect( expect(await value(`
await value(`
const out = [] const out = []
for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item) for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item)
return out return out
`), `)).toEqual(["0:a", "1:b"])
).toEqual(["0:a", "1:b"])
expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]]) expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]])
}) })
}) })
@@ -352,33 +300,40 @@ describe("compound assignment matches its binary operator", () => {
} }
test("sandbox Date += concatenates its string form, like d = d + 1", async () => { test("sandbox Date += concatenates its string form, like d = d + 1", async () => {
const result = await pair(`let d = new Date(1000); d += 1; return d`, `let d = new Date(1000); d = d + 1; return d`) const result = await pair(
`let d = new Date(1000); d += 1; return d`,
`let d = new Date(1000); d = d + 1; return d`,
)
expect(result).toBe("1970-01-01T00:00:01.000Z1") expect(result).toBe("1970-01-01T00:00:01.000Z1")
}) })
test("sandbox Date numeric compound ops use its time value", async () => { test("sandbox Date numeric compound ops use its time value", async () => {
expect( expect(await pair(
await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`), `let d = new Date(1000); d -= 400; return d`,
).toBe(600) `let d = new Date(1000); d = d - 400; return d`,
expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe( )).toBe(600)
250, expect(await pair(
) `let d = new Date(1000); d /= 4; return d`,
`let d = new Date(1000); d = d / 4; return d`,
)).toBe(250)
}) })
test("string += object/array matches x = x + obj", async () => { test("string += object/array matches x = x + obj", async () => {
expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).toBe( expect(await pair(
"a[object Object]", `let x = "a"; x += { b: 1 }; return x`,
) `let x = "a"; x = x + { b: 1 }; return x`,
expect(await pair(`let x = "a"; x += [1, 2]; return x`, `let x = "a"; x = x + [1, 2]; return x`)).toBe("a1,2") )).toBe("a[object Object]")
expect(await pair(
`let x = "a"; x += [1, 2]; return x`,
`let x = "a"; x = x + [1, 2]; return x`,
)).toBe("a1,2")
}) })
test("compound assignment through a member target coerces the same way", async () => { test("compound assignment through a member target coerces the same way", async () => {
expect( expect(await pair(
await pair( `const o = { s: "t" }; o.s += new Date(0); return o.s`,
`const o = { s: "t" }; o.s += new Date(0); return o.s`, `const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`,
`const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`, )).toBe("t1970-01-01T00:00:00.000Z")
),
).toBe("t1970-01-01T00:00:00.000Z")
}) })
test("numeric and string compound operators sweep identically to their expansions", async () => { test("numeric and string compound operators sweep identically to their expansions", async () => {
+27 -54
View File
@@ -23,7 +23,7 @@ const sleepyTool = (trace: Trace) =>
input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }), input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
output: Schema.Number, output: Schema.Number,
run: ({ id, ms }) => run: ({ id, ms }) =>
Effect.gen(function* () { Effect.gen(function*() {
trace.starts.push(id) trace.starts.push(id)
trace.active += 1 trace.active += 1
trace.maxActive = Math.max(trace.maxActive, trace.active) trace.maxActive = Math.max(trace.maxActive, trace.active)
@@ -31,14 +31,10 @@ const sleepyTool = (trace: Trace) =>
trace.active -= 1 trace.active -= 1
trace.completed += 1 trace.completed += 1
return id return id
}).pipe( }).pipe(Effect.onInterrupt(() => Effect.sync(() => {
Effect.onInterrupt(() => trace.active -= 1
Effect.sync(() => { trace.interrupted += 1
trace.active -= 1 }))),
trace.interrupted += 1
}),
),
),
}) })
const failingTool = Tool.make({ const failingTool = Tool.make({
@@ -50,13 +46,11 @@ const failingTool = Tool.make({
const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise<ExecuteResult> => { const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise<ExecuteResult> => {
const trace = options.trace ?? makeTrace() const trace = options.trace ?? makeTrace()
return Effect.runPromise( return Effect.runPromise(CodeMode.execute({
CodeMode.execute({ tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } }, code,
code, ...(options.limits ? { limits: options.limits } : {}),
...(options.limits ? { limits: options.limits } : {}), }))
}),
)
} }
const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => { const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => {
@@ -127,8 +121,7 @@ describe("first-class promise values", () => {
}) })
test("an awaited failure is catchable exactly like a synchronous throw", async () => { test("an awaited failure is catchable exactly like a synchronous throw", async () => {
expect( expect(await value(`
await value(`
const p = tools.host.fail({}) const p = tools.host.fail({})
try { try {
await p await p
@@ -136,8 +129,7 @@ describe("first-class promise values", () => {
} catch (e) { } catch (e) {
return e.message return e.message
} }
`), `)).toBe("Lookup refused")
).toBe("Lookup refused")
}) })
test("a fire-and-forget call completes before the execution ends", async () => { test("a fire-and-forget call completes before the execution ends", async () => {
@@ -194,24 +186,20 @@ describe("promises at data boundaries", () => {
describe("Promise.all over arbitrary arrays", () => { describe("Promise.all over arbitrary arrays", () => {
test("mixes promises and plain values, preserving order", async () => { test("mixes promises and plain values, preserving order", async () => {
expect( expect(await value(`
await value(`
return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42]) return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42])
`), `)).toEqual([1, "plain", 2, 42])
).toEqual([1, "plain", 2, 42])
}) })
test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => { test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => {
expect( expect(await value(`
await value(`
const calls = [] const calls = []
calls.push(tools.host.sleepy({ id: 1 })) calls.push(tools.host.sleepy({ id: 1 }))
calls.push(7) calls.push(7)
const more = [tools.host.sleepy({ id: 2 })] const more = [tools.host.sleepy({ id: 2 })]
const batch = [...calls, ...more, "x"] const batch = [...calls, ...more, "x"]
return await Promise.all(batch) return await Promise.all(batch)
`), `)).toEqual([1, 7, 2, "x"])
).toEqual([1, 7, 2, "x"])
}) })
test("runs items.map tool calls in parallel", async () => { test("runs items.map tool calls in parallel", async () => {
@@ -250,16 +238,14 @@ describe("Promise.all over arbitrary arrays", () => {
}) })
test("rejects with the first failure, catchable in-program", async () => { test("rejects with the first failure, catchable in-program", async () => {
expect( expect(await value(`
await value(`
try { try {
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})]) await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
return "no" return "no"
} catch (e) { } catch (e) {
return e.message return e.message
} }
`), `)).toBe("Lookup refused")
).toBe("Lookup refused")
}) })
test("a non-collection argument is a clear error", async () => { test("a non-collection argument is a clear error", async () => {
@@ -278,16 +264,14 @@ describe("Promise.all over arbitrary arrays", () => {
describe("Promise.allSettled", () => { describe("Promise.allSettled", () => {
test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => { test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
expect( expect(await value(`
await value(`
return await Promise.allSettled([ return await Promise.allSettled([
tools.host.sleepy({ id: 5 }), tools.host.sleepy({ id: 5 }),
tools.host.fail({}), tools.host.fail({}),
"plain", "plain",
Promise.reject(new Error("boom")), Promise.reject(new Error("boom")),
]) ])
`), `)).toEqual([
).toEqual([
{ status: "fulfilled", value: 5 }, { status: "fulfilled", value: 5 },
{ status: "rejected", reason: { name: "Error", message: "Lookup refused" } }, { status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
{ status: "fulfilled", value: "plain" }, { status: "fulfilled", value: "plain" },
@@ -322,8 +306,7 @@ describe("Promise.race", () => {
}) })
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => { test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
expect( expect(await value(`
await value(`
const fast = tools.host.sleepy({ id: 1, ms: 10 }) const fast = tools.host.sleepy({ id: 1, ms: 10 })
const slow = tools.host.sleepy({ id: 2, ms: 5000 }) const slow = tools.host.sleepy({ id: 2, ms: 5000 })
const winner = await Promise.race([fast, slow]) const winner = await Promise.race([fast, slow])
@@ -333,31 +316,23 @@ describe("Promise.race", () => {
} catch (e) { } catch (e) {
return { winner, caught: e.message } return { winner, caught: e.message }
} }
`), `)).toEqual({ winner: 1, caught: "This tool call was interrupted because another value settled a Promise.race first." })
).toEqual({
winner: 1,
caught: "This tool call was interrupted because another value settled a Promise.race first.",
})
}) })
test("a rejection can win the race", async () => { test("a rejection can win the race", async () => {
expect( expect(await value(`
await value(`
try { try {
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })]) await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
return "no" return "no"
} catch (e) { } catch (e) {
return e.message return e.message
} }
`), `)).toBe("Lookup refused")
).toBe("Lookup refused")
}) })
test("a plain value wins over pending promises", async () => { test("a plain value wins over pending promises", async () => {
const trace = makeTrace() const trace = makeTrace()
expect( expect(await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace })).toBe("immediate")
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
).toBe("immediate")
expect(trace.interrupted).toBe(1) expect(trace.interrupted).toBe(1)
}) })
@@ -375,16 +350,14 @@ describe("Promise.resolve / Promise.reject", () => {
}) })
test("reject produces a promise whose await throws the reason", async () => { test("reject produces a promise whose await throws the reason", async () => {
expect( expect(await value(`
await value(`
try { try {
await Promise.reject("nope") await Promise.reject("nope")
return "no" return "no"
} catch (e) { } catch (e) {
return e return e
} }
`), `)).toBe("nope")
).toBe("nope")
}) })
}) })
+14 -19
View File
@@ -83,9 +83,15 @@ describe("pretty signature rendering", () => {
true, true,
) )
expect(pretty).toBe( expect(pretty).toBe(
["{", " /** Search filter */", " filter?: {", " /** Issue state */", " state?: string", " }", "}"].join( [
"\n", "{",
), " /** Search filter */",
" filter?: {",
" /** Issue state */",
" state?: string",
" }",
"}",
].join("\n"),
) )
}) })
@@ -113,14 +119,7 @@ describe("pretty signature rendering", () => {
expect(pretty).toContain(" /** @deprecated */\n legacy?: string") expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
expect(pretty).toContain(" /** @format uri */\n homepage?: string") expect(pretty).toContain(" /** @format uri */\n homepage?: string")
expect(pretty).toContain( expect(pretty).toContain(
[ [" /**", ' * @default ["a","b"]', " * @minItems 2", " * @maxItems 5", " */", " tags?: Array<string>"].join("\n"),
" /**",
' * @default ["a","b"]',
" * @minItems 2",
" * @maxItems 5",
" */",
" tags?: Array<string>",
].join("\n"),
) )
}) })
@@ -213,11 +212,7 @@ describe("non-identifier property names render as quoted keys", () => {
const tool = Tool.make({ const tool = Tool.make({
description: "Adapter tool with awkward field names", description: "Adapter tool with awkward field names",
input: rawSchema, input: rawSchema,
output: { output: { type: "object", properties: { "content-type": { type: "string" } }, required: ["content-type"] } as const,
type: "object",
properties: { "content-type": { type: "string" } },
required: ["content-type"],
} as const,
run: () => Effect.succeed({ "content-type": "text/plain" }), run: () => Effect.succeed({ "content-type": "text/plain" }),
}) })
expect(inputTypeScript(tool)).toContain('"foo-bar"?: string') expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
@@ -274,9 +269,9 @@ describe("pretty signatures in search results", () => {
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } }) const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const search = async (query: string) => { const search = async (query: string) => {
const result = await Effect.runPromise( const result = await Effect.runPromise(runtime.execute(
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`), `return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`,
) ))
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed") if (!result.ok) throw new Error("search failed")
return result.value as { items: Array<{ path: string; signature: string }>; total: number } return result.value as { items: Array<{ path: string; signature: string }>; total: number }
+48 -116
View File
@@ -40,11 +40,7 @@ describe("Date", () => {
}) })
test("UTC getters read calendar components", async () => { test("UTC getters read calendar components", async () => {
expect( expect(await value(`const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`)).toEqual([2024, 2, 5, 6, 7, 8, 9])
await value(
`const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`,
),
).toEqual([2024, 2, 5, 6, 7, 8, 9])
}) })
test("invalid dates yield NaN times, guardable in-sandbox", async () => { test("invalid dates yield NaN times, guardable in-sandbox", async () => {
@@ -53,9 +49,7 @@ describe("Date", () => {
}) })
test("toISOString on an invalid date is a catchable error", async () => { test("toISOString on an invalid date is a catchable error", async () => {
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe( expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe("caught")
"caught",
)
}) })
test("template interpolation renders the ISO form", async () => { test("template interpolation renders the ISO form", async () => {
@@ -78,18 +72,14 @@ describe("Date", () => {
}) })
test("sorting dates with a numeric comparator", async () => { test("sorting dates with a numeric comparator", async () => {
expect( expect(await value(`
await value(`
const dates = [new Date(3000), new Date(1000), new Date(2000)] const dates = [new Date(3000), new Date(1000), new Date(2000)]
return dates.sort((a, b) => a - b).map((d) => d.getTime()) return dates.sort((a, b) => a - b).map((d) => d.getTime())
`), `)).toEqual([1000, 2000, 3000])
).toEqual([1000, 2000, 3000])
}) })
test("new Date(year, month, day) accepts component form", async () => { test("new Date(year, month, day) accepts component form", async () => {
expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([ expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([2024, 0, 2])
2024, 0, 2,
])
}) })
test("typeof and unknown properties are forgiving", async () => { test("typeof and unknown properties are forgiving", async () => {
@@ -105,31 +95,25 @@ describe("RegExp", () => {
}) })
test("exec exposes captures and index", async () => { test("exec exposes captures and index", async () => {
expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual( expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual({
{ full: "abb",
full: "abb", group: "bb",
group: "bb", index: 2,
index: 2, })
},
)
expect(await value(`return /a/.exec("zzz")`)).toBeNull() expect(await value(`return /a/.exec("zzz")`)).toBeNull()
}) })
test("named groups read through", async () => { test("named groups read through", async () => {
expect( expect(await value(`const m = /(?<word>[a-z]+)-(?<num>\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`)).toBe("ab42")
await value(`const m = /(?<word>[a-z]+)-(?<num>\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`),
).toBe("ab42")
}) })
test("global exec advances lastIndex across calls", async () => { test("global exec advances lastIndex across calls", async () => {
expect( expect(await value(`
await value(`
const r = /\\d+/g const r = /\\d+/g
const first = r.exec("a1b22c") const first = r.exec("a1b22c")
const second = r.exec("a1b22c") const second = r.exec("a1b22c")
return [first[0], second[0]] return [first[0], second[0]]
`), `)).toEqual(["1", "22"])
).toEqual(["1", "22"])
}) })
test("string match: non-global carries index, global lists all matches", async () => { test("string match: non-global carries index, global lists all matches", async () => {
@@ -210,51 +194,34 @@ describe("RegExp", () => {
describe("Map", () => { describe("Map", () => {
test("get/set/has/size with chaining", async () => { test("get/set/has/size with chaining", async () => {
expect( expect(await value(`
await value(`
const m = new Map() const m = new Map()
m.set("a", 1).set("b", 2) m.set("a", 1).set("b", 2)
return { a: m.get("a"), b: m.get("b"), has: m.has("a"), miss: m.get("zz") === undefined, size: m.size } return { a: m.get("a"), b: m.get("b"), has: m.has("a"), miss: m.get("zz") === undefined, size: m.size }
`), `)).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 })
).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 })
}) })
test("object keys use identity", async () => { test("object keys use identity", async () => {
expect( expect(await value(`
await value(`
const key = { id: 1 } const key = { id: 1 }
const m = new Map() const m = new Map()
m.set(key, "hit") m.set(key, "hit")
return [m.get(key), m.get({ id: 1 }) === undefined] return [m.get(key), m.get({ id: 1 }) === undefined]
`), `)).toEqual(["hit", true])
).toEqual(["hit", true])
}) })
test("construction from entry pairs and another Map", async () => { test("construction from entry pairs and another Map", async () => {
expect(await value(`const m = new Map([["a", 1], ["b", 2]]); return m.get("b")`)).toBe(2) expect(await value(`const m = new Map([["a", 1], ["b", 2]]); return m.get("b")`)).toBe(2)
expect( expect(await value(`const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`)).toEqual([1, 2, false])
await value(
`const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`,
),
).toEqual([1, 2, false])
expect((await error(`return new Map("nope")`)).message).toMatch(/\[key, value\] pairs/) expect((await error(`return new Map("nope")`)).message).toMatch(/\[key, value\] pairs/)
expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/) expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/)
}) })
test("keys/values/entries return arrays", async () => { test("keys/values/entries return arrays", async () => {
expect( expect(await value(`
await value(`
const m = new Map([["a", 1], ["b", 2]]) const m = new Map([["a", 1], ["b", 2]])
return { keys: m.keys(), values: m.values(), entries: m.entries() } return { keys: m.keys(), values: m.values(), entries: m.entries() }
`), `)).toEqual({ keys: ["a", "b"], values: [1, 2], entries: [["a", 1], ["b", 2]] })
).toEqual({
keys: ["a", "b"],
values: [1, 2],
entries: [
["a", 1],
["b", 2],
],
})
}) })
test("Object.fromEntries(map) and Array.from(map)", async () => { test("Object.fromEntries(map) and Array.from(map)", async () => {
@@ -263,15 +230,13 @@ describe("Map", () => {
}) })
test("for...of iterates [key, value] pairs with destructuring", async () => { test("for...of iterates [key, value] pairs with destructuring", async () => {
expect( expect(await value(`
await value(`
const m = new Map([["a", 1], ["b", 2]]) const m = new Map([["a", 1], ["b", 2]])
let total = 0 let total = 0
let names = "" let names = ""
for (const [key, count] of m) { names += key; total += count } for (const [key, count] of m) { names += key; total += count }
return names + total return names + total
`), `)).toBe("ab3")
).toBe("ab3")
}) })
test("spread produces entry pairs", async () => { test("spread produces entry pairs", async () => {
@@ -279,38 +244,32 @@ describe("Map", () => {
}) })
test("forEach passes (value, key)", async () => { test("forEach passes (value, key)", async () => {
expect( expect(await value(`
await value(`
const m = new Map([["a", 1], ["b", 2]]) const m = new Map([["a", 1], ["b", 2]])
const seen = [] const seen = []
m.forEach((count, key) => seen.push(key + count)) m.forEach((count, key) => seen.push(key + count))
return seen return seen
`), `)).toEqual(["a1", "b2"])
).toEqual(["a1", "b2"])
}) })
test("delete and clear", async () => { test("delete and clear", async () => {
expect( expect(await value(`
await value(`
const m = new Map([["a", 1], ["b", 2]]) const m = new Map([["a", 1], ["b", 2]])
const removed = m.delete("a") const removed = m.delete("a")
const missed = m.delete("zz") const missed = m.delete("zz")
const sizeAfterDelete = m.size const sizeAfterDelete = m.size
m.clear() m.clear()
return [removed, missed, sizeAfterDelete, m.size] return [removed, missed, sizeAfterDelete, m.size]
`), `)).toEqual([true, false, 1, 0])
).toEqual([true, false, 1, 0])
}) })
test("counting idiom: grouped tallies", async () => { test("counting idiom: grouped tallies", async () => {
expect( expect(await value(`
await value(`
const words = ["a", "b", "a", "c", "a"] const words = ["a", "b", "a", "c", "a"]
const counts = new Map() const counts = new Map()
for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1) for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1)
return Object.fromEntries(counts) return Object.fromEntries(counts)
`), `)).toEqual({ a: 3, b: 1, c: 1 })
).toEqual({ a: 3, b: 1, c: 1 })
}) })
test("maps serialize to {} at the boundary, like JSON", async () => { test("maps serialize to {} at the boundary, like JSON", async () => {
@@ -327,14 +286,12 @@ describe("Map", () => {
describe("Set", () => { describe("Set", () => {
test("add/has/delete/size with chaining", async () => { test("add/has/delete/size with chaining", async () => {
expect( expect(await value(`
await value(`
const s = new Set() const s = new Set()
s.add(1).add(2).add(1) s.add(1).add(2).add(1)
const removed = s.delete(2) const removed = s.delete(2)
return [s.size, s.has(1), s.has(2), removed] return [s.size, s.has(1), s.has(2), removed]
`), `)).toEqual([1, true, false, true])
).toEqual([1, true, false, true])
}) })
test("dedupe idiom: [...new Set(items)]", async () => { test("dedupe idiom: [...new Set(items)]", async () => {
@@ -351,13 +308,11 @@ describe("Set", () => {
}) })
test("for...of iterates values", async () => { test("for...of iterates values", async () => {
expect( expect(await value(`
await value(`
let total = 0 let total = 0
for (const n of new Set([1, 2, 3])) total += n for (const n of new Set([1, 2, 3])) total += n
return total return total
`), `)).toBe(6)
).toBe(6)
}) })
test("sets serialize to {} at the boundary, like JSON", async () => { test("sets serialize to {} at the boundary, like JSON", async () => {
@@ -383,32 +338,21 @@ describe("stdlib integration", () => {
}) })
test("dates inside Map values survive in-sandbox reads", async () => { test("dates inside Map values survive in-sandbox reads", async () => {
expect( expect(await value(`
await value(`
const m = new Map([["start", new Date(1000)]]) const m = new Map([["start", new Date(1000)]])
return m.get("start").getTime() return m.get("start").getTime()
`), `)).toBe(1000)
).toBe(1000)
}) })
test("instanceof recognizes the stdlib value types", async () => { test("instanceof recognizes the stdlib value types", async () => {
expect( expect(await value(`return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`)).toEqual([true, true, true, true])
await value( expect(await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`)).toEqual([true, true, true, false])
`return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`,
),
).toEqual([true, true, true, true])
expect(
await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`),
).toEqual([true, true, true, false])
expect(await value(`return [new Map() instanceof Set, "s" instanceof Date]`)).toEqual([false, false]) expect(await value(`return [new Map() instanceof Set, "s" instanceof Date]`)).toEqual([false, false])
expect( expect(await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`)).toBe(true)
await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`),
).toBe(true)
}) })
test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => { test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => {
expect( expect(await value(`
await value(`
const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]' const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]'
const rows = JSON.parse(raw) const rows = JSON.parse(raw)
const tags = new Set() const tags = new Set()
@@ -419,34 +363,27 @@ describe("stdlib integration", () => {
byDay.set(day, (byDay.get(day) ?? 0) + 1) byDay.set(day, (byDay.get(day) ?? 0) + 1)
} }
return { tags: [...tags].sort((a, b) => (a < b ? -1 : 1)), byDay: Object.fromEntries(byDay) } return { tags: [...tags].sort((a, b) => (a < b ? -1 : 1)), byDay: Object.fromEntries(byDay) }
`), `)).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } })
).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } })
}) })
}) })
describe("sandbox values at intra-sandbox checkpoints", () => { describe("sandbox values at intra-sandbox checkpoints", () => {
test("Object.values/entries keep Dates usable", async () => { test("Object.values/entries keep Dates usable", async () => {
expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0) expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0)
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe( expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe("d:0")
"d:0",
)
}) })
test("Object.assign keeps Maps usable", async () => { test("Object.assign keeps Maps usable", async () => {
expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe( expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(1)
1,
)
}) })
test("object and array spread keep sandbox values usable", async () => { test("object and array spread keep sandbox values usable", async () => {
expect( expect(await value(`
await value(`
const src = { m: new Map([["a", 1]]) } const src = { m: new Map([["a", 1]]) }
const copy = { ...src } const copy = { ...src }
copy.m.set("b", 2) copy.m.set("b", 2)
return [copy.m.get("a"), src.m.get("b")] return [copy.m.get("a"), src.m.get("b")]
`), `)).toEqual([1, 2])
).toEqual([1, 2])
expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000) expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000)
}) })
@@ -467,10 +404,7 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
}) })
test("the host boundary still serializes JSON forms: results, JSON.stringify, and tool arguments", async () => { test("the host boundary still serializes JSON forms: results, JSON.stringify, and tool arguments", async () => {
expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ d: "1970-01-01T00:00:00.000Z", m: {} })
d: "1970-01-01T00:00:00.000Z",
m: {},
})
expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}') expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}')
const observed: Array<unknown> = [] const observed: Array<unknown> = []
@@ -483,12 +417,10 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
return "ok" return "ok"
}), }),
}) })
const result = await Effect.runPromise( const result = await Effect.runPromise(CodeMode.execute({
CodeMode.execute({ tools: { host: { capture } },
tools: { host: { capture } }, code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`,
code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`, }))
}),
)
expect(result.ok).toBe(true) expect(result.ok).toBe(true)
expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }]) expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }])
}) })
+1 -6
View File
@@ -77,12 +77,7 @@ const layer = Layer.effect(
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const api = const api =
// A native api with a package is explicitly targeted; only package-less, model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
// settings-less native apis are placeholders that inherit the provider api.
model.api.type === "native" &&
model.api.package === undefined &&
!model.api.url &&
Object.keys(model.api.settings).length === 0
? { ...provider.api, id: model.api.id } ? { ...provider.api, id: model.api.id }
: model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url : model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url
? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } } ? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } }
+1 -1
View File
@@ -230,7 +230,7 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
if (existing && existing !== node) { if (existing && existing !== node) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
} }
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap)) hoisted.set(node.name, node)
return group([]) return group([])
} }
if (node.kind === "unbound") { if (node.kind === "unbound") {
-4
View File
@@ -89,10 +89,6 @@ export function buildLocationServiceMap(
LayerMap.make( LayerMap.make(
(ref: Location.Ref) => { (ref: Location.Ref) => {
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
// those back out.
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
return LayerNode.compile(location.node).pipe( return LayerNode.compile(location.node).pipe(
+10 -61
View File
@@ -1,10 +1,9 @@
import { createServer } from "node:http" import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Stream } from "effect" import { Deferred, Effect } from "effect"
import type { Scope } from "effect" import type { Scope } from "effect"
import { Credential } from "../../credential" import { Credential } from "../../credential"
import { EventV2 } from "../../event"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
@@ -18,7 +17,6 @@ const callbackPort = 1455
const pollingSafetyMargin = 3000 const pollingSafetyMargin = 3000
const browserMethodID = Integration.MethodID.make("chatgpt-browser") const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless") const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
const codexPackage = "@opencode-ai/llm/providers/openai/codex"
type Pkce = { type Pkce = {
verifier: string verifier: string
@@ -156,55 +154,24 @@ const headless = {
export const OpenAIPlugin = define({ export const OpenAIPlugin = define({
id: "openai", id: "openai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
yield* ctx.integration.transform((draft) => { yield* ctx.integration.transform((draft) => {
draft.method.update(browser) draft.method.update(browser)
draft.method.update(headless) draft.method.update(headless)
}) })
yield* ctx.catalog.transform( yield* ctx.catalog.transform(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
const connection = yield* ctx.integration.connection.active("openai")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
const chatgpt = isChatGPT(credential)
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue if (item.provider.api.package !== "@ai-sdk/openai") continue
if (item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) { if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a // OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog. // chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false model.enabled = false
}) })
}
if (!chatgpt) continue
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (!eligible(draft.api.id)) {
draft.enabled = false
return
}
draft.cost = draft.cost.map((cost) => ({ ...cost, input: 0, output: 0, cache: { read: 0, write: 0 } }))
draft.api = {
type: "native",
id: draft.api.id,
package: codexPackage,
settings:
draft.api.type === "aisdk"
? { ...item.provider.api.settings, ...draft.api.settings }
: draft.api.settings,
}
})
}
} }
}), }),
) )
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
Stream.runForEach(() => ctx.catalog.reload()),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return if (evt.package !== "@ai-sdk/openai") return
@@ -302,34 +269,16 @@ function authorizeURL(redirect: string, pkce: Pkce, state: string) {
codex_cli_simplified_flow: "true", codex_cli_simplified_flow: "true",
state, state,
originator: "opencode", originator: "opencode",
}).toString()}` })}`
} }
function extractAccountID(tokens: TokenResponse) { function extractAccountID(tokens: TokenResponse) {
return claim(tokens.id_token) ?? claim(tokens.access_token) return claim(tokens.id_token) ?? claim(tokens.access_token)
} }
function isChatGPT(credential: { readonly type: string; readonly methodID?: string } | undefined) {
return (
credential?.type === "oauth" &&
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
)
}
const chatgptAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const chatgptDisallowed = new Set(["gpt-5.5-pro"])
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
function eligible(apiID: string) {
if (chatgptAllowed.has(apiID)) return true
if (chatgptDisallowed.has(apiID)) return false
const match = apiID.match(/^gpt-(\d+\.\d+)/)
return match ? Number.parseFloat(match[1]) > 5.4 : false
}
function claim(token: string) { function claim(token: string) {
const part = token.split(".")[1] const part = token.split(".")[1]
if (!part) return undefined if (!part) return
try { try {
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
return ( return (
@@ -338,6 +287,6 @@ function claim(token: string) {
claims.organizations?.[0]?.id claims.organizations?.[0]?.id
) )
} catch { } catch {
return undefined return
} }
} }
+1 -44
View File
@@ -1,9 +1,7 @@
export * as ProviderV2 from "./provider" export * as ProviderV2 from "./provider"
import { Effect, Schema, Types } from "effect" import { Types } from "effect"
import { Provider } from "@opencode-ai/schema/provider" import { Provider } from "@opencode-ai/schema/provider"
import { ProviderPackage } from "@opencode-ai/llm/provider-package"
import { Anthropic, OpenAI, OpenAICodex, OpenAICompatible } from "@opencode-ai/llm/providers"
export const ID = Provider.ID export const ID = Provider.ID
export type ID = typeof ID.Type export type ID = typeof ID.Type
@@ -25,44 +23,3 @@ export const Info = Provider.Info
export type Info = Provider.Info export type Info = Provider.Info
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi } export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
export class PackageLoadError extends Schema.TaggedErrorClass<PackageLoadError>()("ProviderV2.PackageLoadError", {
specifier: Schema.String,
reason: Schema.String,
}) {
override get message() {
return `Failed to load provider package ${this.specifier}: ${this.reason}`
}
}
type PackageModule = { readonly model: ProviderPackage.Definition["model"] }
const builtins: Record<string, PackageModule> = {
"@opencode-ai/llm/providers/openai": OpenAI,
"@opencode-ai/llm/providers/anthropic": Anthropic,
"@opencode-ai/llm/providers/openai-compatible": OpenAICompatible,
"@opencode-ai/llm/providers/openai/codex": OpenAICodex,
}
export const loadPackage = (
specifier: string,
): Effect.Effect<ProviderPackage.Definition["model"], PackageLoadError> => {
const builtin = builtins[specifier]
if (builtin) return Effect.succeed(builtin.model)
return Effect.tryPromise({
try: () => import(specifier),
catch: (cause) =>
new PackageLoadError({ specifier, reason: cause instanceof Error ? cause.message : String(cause) }),
}).pipe(
Effect.flatMap((module) => {
if (hasModel(module)) return Effect.succeed(module.model)
return Effect.fail(new PackageLoadError({ specifier, reason: "missing model export" }))
}),
)
}
export const load = loadPackage
function hasModel(module: unknown): module is PackageModule {
return typeof module === "object" && module !== null && "model" in module && typeof module.model === "function"
}
+60 -79
View File
@@ -1,8 +1,12 @@
export * as SessionRunnerModel from "./model" export * as SessionRunnerModel from "./model"
import { makeLocationNode } from "../../effect/app-node" import { makeLocationNode } from "../../effect/app-node"
import { Model } from "@opencode-ai/llm" import { type Model } from "@opencode-ai/llm"
import { Context, Effect, Layer, Predicate, Schema } from "effect" import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer" import { produce } from "immer"
import { Catalog } from "../../catalog" import { Catalog } from "../../catalog"
import { Credential } from "../../credential" import { Credential } from "../../credential"
@@ -65,7 +69,6 @@ export type Error =
| ModelUnavailableError | ModelUnavailableError
| VariantUnavailableError | VariantUnavailableError
| UnsupportedApiError | UnsupportedApiError
| ProviderV2.PackageLoadError
| Integration.AuthorizationError | Integration.AuthorizationError
export interface Interface { export interface Interface {
@@ -77,6 +80,27 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */ /** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
const body = model.request.body
const httpBody = Object.hasOwn(body, "apiKey")
? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey"))
: body
return route.with({
provider: model.providerID,
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
headers: model.request.headers,
http: { body: httpBody },
limits: { context: model.limit.context, output: model.limit.output },
})
}
const withVariant = ( const withVariant = (
model: ModelV2.Info, model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined, variantID: ModelV2.VariantID | undefined,
@@ -107,95 +131,52 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = ( export const fromCatalogModel = (
model: ModelV2.Info, model: ModelV2.Info,
credential?: Credential.Value, credential?: Credential.Value,
): Effect.Effect<Model, UnsupportedApiError | ProviderV2.PackageLoadError> => { ): Effect.Effect<Model, UnsupportedApiError> => {
const resolved = const resolved =
credential?.type !== "key" || credential.metadata === undefined credential?.type !== "key" || credential.metadata === undefined
? model ? model
: produce(model, (draft) => { : produce(model, (draft) => {
Object.assign(draft.request.body, credential.metadata) Object.assign(draft.request.body, credential.metadata)
}) })
return packageSpecifier(resolved).pipe( const key = apiKey(resolved, credential)
Effect.flatMap((specifier) => if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
ProviderV2.loadPackage(specifier).pipe( return Effect.succeed(
Effect.map((load) => { withDefaults(resolved, OpenAIResponses.route)
const selected = load(resolved.api.id ?? resolved.id, { .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
...resolved.api.settings, .model({ id: resolved.api.id }),
...(credential?.type === "oauth" ? credential.metadata : undefined), )
baseURL: resolved.api.url ?? settingsString(resolved.api.settings, "baseURL"), }
apiKey: apiKey(resolved, credential), if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
providerOptions: requestSettings(resolved.request), return Effect.succeed(
headers: resolved.request.headers, withDefaults(resolved, AnthropicMessages.route)
body: stripApiKey(resolved.request.body), .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
limits: { context: resolved.limit.context, output: resolved.limit.output }, .model({ id: resolved.api.id }),
}) )
return Model.update(selected, { }
provider: resolved.providerID, if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
route: selected.route.with({ provider: resolved.providerID }), return Effect.succeed(
}) withDefaults(resolved, OpenAICompatibleChat.route)
}), .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
), .model({ id: resolved.api.id }),
), )
}
return Effect.fail(
new UnsupportedApiError({
providerID: resolved.providerID,
modelID: resolved.id,
api: apiName(resolved),
}),
) )
} }
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) => export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) =>
withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential))) withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential)))
/** Legacy aisdk catalog entries dispatch to the equivalent native provider packages. */
const aisdkPackages: Record<string, string | undefined> = {
"@ai-sdk/openai": "@opencode-ai/llm/providers/openai",
"@ai-sdk/anthropic": "@opencode-ai/llm/providers/anthropic",
"@ai-sdk/openai-compatible": "@opencode-ai/llm/providers/openai-compatible",
}
export const supported = (model: ModelV2.Info) => export const supported = (model: ModelV2.Info) =>
(model.api.type === "native" && model.api.package !== undefined) || model.api.type === "aisdk" &&
(model.api.type === "aisdk" && (model.api.package === "@ai-sdk/openai" ||
aisdkPackages[model.api.package] !== undefined && model.api.package === "@ai-sdk/anthropic" ||
// The openai-compatible package has no default endpoint; a URL is required. (model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined))
(model.api.package !== "@ai-sdk/openai-compatible" || model.api.url !== undefined))
const packageSpecifier = (model: ModelV2.Info): Effect.Effect<string, UnsupportedApiError> => {
if (supported(model)) {
if (model.api.type === "native" && model.api.package !== undefined) return Effect.succeed(model.api.package)
const specifier = model.api.type === "aisdk" ? aisdkPackages[model.api.package] : undefined
if (specifier !== undefined) return Effect.succeed(specifier)
}
return Effect.fail(
new UnsupportedApiError({
providerID: model.providerID,
modelID: model.id,
api: apiName(model),
}),
)
}
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return credential.key
if (credential?.type === "oauth") return credential.access
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
if (typeof value === "string") return value
return undefined
}
const stripApiKey = (body: ModelV2.Info["request"]["body"]) => {
if (!Object.hasOwn(body, "apiKey")) return body
return Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey"))
}
const requestSettings = (request: ModelV2.Info["request"]) => {
if (!("settings" in request)) return undefined
const settings = request.settings
if (!Predicate.isReadonlyObject(settings)) return undefined
if (Object.keys(settings).length === 0) return undefined
return settings
}
const settingsString = (settings: ModelV2.Info["api"]["settings"], key: string) => {
const value = settings?.[key]
if (typeof value === "string") return value
return undefined
}
/** Resolves models from the catalog belonging to the current Location runtime. */ /** Resolves models from the catalog belonging to the current Location runtime. */
export const locationLayer = Layer.effect( export const locationLayer = Layer.effect(
-34
View File
@@ -213,40 +213,6 @@ describe("CatalogV2", () => {
}), }),
) )
it.effect("keeps an explicit native package api over the provider api", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai",
}
})
catalog.model.update(providerID, modelID, (model) => {
// Plugins retarget models at explicit native packages (e.g. ChatGPT
// subscription models at the codex package); empty settings must not
// demote the api back to the provider placeholder rule.
model.api = {
type: "native",
id: model.api.id,
package: "@opencode-ai/llm/providers/openai/codex",
settings: {},
}
})
})
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
id: modelID,
type: "native",
package: "@opencode-ai/llm/providers/openai/codex",
settings: {},
})
}),
)
it.effect("resolves provider and model request merges", () => it.effect("resolves provider and model request merges", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
@@ -3,7 +3,6 @@ import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -17,6 +16,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () { const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)) yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
@@ -61,6 +61,7 @@ describe("OpenAIPlugin", () => {
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
yield* addPlugin() yield* addPlugin()
const result = yield* aisdk.runSDK({ const result = yield* aisdk.runSDK({
@@ -77,6 +78,7 @@ describe("OpenAIPlugin", () => {
it.effect("ignores non-OpenAI SDK packages", () => it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
yield* addPlugin() yield* addPlugin()
const result = yield* aisdk.runSDK({ const result = yield* aisdk.runSDK({
@@ -93,6 +95,7 @@ describe("OpenAIPlugin", () => {
it.effect("uses the Responses API for language models", () => it.effect("uses the Responses API for language models", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin()
@@ -111,6 +114,7 @@ describe("OpenAIPlugin", () => {
it.effect("ignores non-OpenAI providers", () => it.effect("ignores non-OpenAI providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service const aisdk = yield* AISDK.Service
const calls: string[] = [] const calls: string[] = []
yield* addPlugin() yield* addPlugin()
@@ -169,54 +173,4 @@ describe("OpenAIPlugin", () => {
).toBe(true) ).toBe(true)
}), }),
) )
it.effect("routes eligible models to the codex package for ChatGPT connections", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((catalog) => {
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
api: { type: "aisdk", package: "@ai-sdk/openai", settings: { store: false } },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.3-codex-spark"), (draft) => {
draft.api = { id: ModelV2.ID.make("gpt-5.3-codex-spark"), type: "aisdk", package: "@ai-sdk/openai" }
draft.cost = [{ input: 1, output: 2, cache: { read: 3, write: 4 } }]
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), (draft) => {
draft.api = { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "@ai-sdk/openai" }
})
})
yield* credentials.create({
integrationID: Integration.ID.make("openai"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-browser"),
access: "access",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { accountID: "account-123" },
}),
})
yield* addPlugin()
expect(
required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.3-codex-spark"))),
).toMatchObject({
enabled: true,
api: {
type: "native",
id: "gpt-5.3-codex-spark",
package: "@opencode-ai/llm/providers/openai/codex",
settings: { store: false },
},
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
})
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(false)
}),
)
}) })
@@ -20,12 +20,7 @@ type Api =
readonly url?: string readonly url?: string
readonly settings?: Record<string, unknown> readonly settings?: Record<string, unknown>
} }
| { | { readonly type: "native"; readonly url?: string; readonly settings: Record<string, unknown> }
readonly type: "native"
readonly package?: string
readonly url?: string
readonly settings: Record<string, unknown>
}
const model = (api: Api, variants: ModelV2.Info["variants"] = []) => const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
ModelV2.Info.make({ ModelV2.Info.make({
@@ -274,71 +269,6 @@ describe("SessionRunnerModel", () => {
}), }),
) )
it.effect("maps native provider package models into bearer-authenticated routes", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({
type: "native",
package: "@opencode-ai/llm/providers/openai",
url: "https://openai.example/v1",
settings: {},
}),
request: { headers: {}, body: {} },
}),
Credential.Key.make({ type: "key", key: "secret" }),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route).toMatchObject({
id: "openai-responses",
endpoint: { baseURL: "https://openai.example/v1" },
})
expect(headers.authorization).toBe("Bearer secret")
}),
)
it.effect("routes native ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({
type: "native",
package: "@opencode-ai/llm/providers/openai/codex",
settings: {},
}),
request: { headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("chatgpt-browser"),
access: "oauth-token",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { accountID: "account-123" },
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer oauth-token")
expect(headers["chatgpt-account-id"]).toBe("account-123")
}),
)
it.effect("prefers stored credentials over configured auth", () => it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () { Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }) const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
@@ -412,11 +342,6 @@ describe("SessionRunnerModel", () => {
), ),
).toBe(false) ).toBe(false)
expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false) expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false)
expect(
SessionRunnerModel.supported(
model({ type: "native", package: "@opencode-ai/llm/providers/openai", settings: {} }),
),
).toBe(true)
}), }),
) )
}) })
-2
View File
@@ -14,7 +14,6 @@
".": "./src/index.ts", ".": "./src/index.ts",
"./route": "./src/route/index.ts", "./route": "./src/route/index.ts",
"./provider": "./src/provider.ts", "./provider": "./src/provider.ts",
"./provider-package": "./src/provider-package.ts",
"./providers": "./src/providers/index.ts", "./providers": "./src/providers/index.ts",
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
"./providers/anthropic": "./src/providers/anthropic.ts", "./providers/anthropic": "./src/providers/anthropic.ts",
@@ -23,7 +22,6 @@
"./providers/github-copilot": "./src/providers/github-copilot.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts",
"./providers/google": "./src/providers/google.ts", "./providers/google": "./src/providers/google.ts",
"./providers/openai": "./src/providers/openai.ts", "./providers/openai": "./src/providers/openai.ts",
"./providers/openai/codex": "./src/providers/openai/codex.ts",
"./providers/openai-compatible": "./src/providers/openai-compatible.ts", "./providers/openai-compatible": "./src/providers/openai-compatible.ts",
"./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts",
"./providers/openrouter": "./src/providers/openrouter.ts", "./providers/openrouter": "./src/providers/openrouter.ts",
-1
View File
@@ -1,7 +1,6 @@
export { LLMClient } from "./route/client" export { LLMClient } from "./route/client"
export { Auth } from "./route/auth" export { Auth } from "./route/auth"
export { Provider } from "./provider" export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error" export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
export type { export type {
RouteModelInput, RouteModelInput,
-18
View File
@@ -1,18 +0,0 @@
export * as ProviderPackage from "./provider-package"
import type { Model } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Readonly<Record<string, unknown>>
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: { readonly context: number; readonly output: number }
}
export interface Definition<S extends Settings = Settings> {
readonly model: (modelID: string, settings: S) => Model
}
export const define = <S extends Settings = Settings>(model: (modelID: string, settings: S) => Model) => model
+4 -17
View File
@@ -2,16 +2,13 @@ import type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options" import type { ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import { ProviderPackage } from "../provider-package" import * as AnthropicMessages from "../protocols/anthropic-messages"
import { AnthropicMessages } from "../protocols/anthropic-messages"
export const id = ProviderID.make("anthropic") export const id = ProviderID.make("anthropic")
export const routes = [AnthropicMessages.route] export const routes = [AnthropicMessages.route]
type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string } export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
export interface AnthropicSettings extends ProviderPackage.Settings {}
const auth = (options: ProviderAuthOption<"optional">) => { const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth if ("auth" in options && options.auth) return options.auth
@@ -34,15 +31,5 @@ export const configure = (input: Config = {}) => {
} }
} }
export const model = ProviderPackage.define((modelID, settings: AnthropicSettings) => export const provider = configure()
AnthropicMessages.route export const model = provider.model
.with({
auth: settings.apiKey === undefined ? Auth.none : Auth.header("x-api-key", settings.apiKey),
endpoint: { baseURL: settings.baseURL },
headers: settings.headers,
providerOptions: settings.providerOptions && { anthropic: settings.providerOptions },
http: { body: settings.body },
limits: settings.limits,
})
.model({ id: modelID }),
)
-1
View File
@@ -6,7 +6,6 @@ export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
export * as GitHubCopilot from "./github-copilot" export * as GitHubCopilot from "./github-copilot"
export * as Google from "./google" export * as Google from "./google"
export * as OpenAI from "./openai" export * as OpenAI from "./openai"
export * as OpenAICodex from "./openai/codex"
export * as OpenAICompatible from "./openai-compatible" export * as OpenAICompatible from "./openai-compatible"
export * as OpenRouter from "./openrouter" export * as OpenRouter from "./openrouter"
export * as XAI from "./xai" export * as XAI from "./xai"
+33 -15
View File
@@ -1,9 +1,8 @@
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { Auth } from "../route/auth" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
import { ProviderPackage } from "../provider-package"
export const id = ProviderID.make("openai-compatible") export const id = ProviderID.make("openai-compatible")
@@ -13,7 +12,10 @@ type GenericModelOptions = RouteDefaultsInput &
readonly baseURL: string readonly baseURL: string
} }
export interface OpenAICompatibleSettings extends ProviderPackage.Settings {} export type FamilyModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
}
export const routes = [OpenAICompatibleChat.route] export const routes = [OpenAICompatibleChat.route]
@@ -33,15 +35,31 @@ export const configure = (input: GenericModelOptions) => {
} }
} }
export const model = ProviderPackage.define((modelID, settings: OpenAICompatibleSettings) => const define = (profile: OpenAICompatibleProfile) => {
OpenAICompatibleChat.route const configureProfile = (input: FamilyModelOptions = {}) => {
.with({ const facade = configure({
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey), ...input,
endpoint: { baseURL: settings.baseURL }, baseURL: input.baseURL ?? profile.baseURL,
headers: settings.headers, provider: profile.provider,
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
http: { body: settings.body },
limits: settings.limits,
}) })
.model({ id: modelID, provider: "openai-compatible" }), return {
) id: ProviderID.make(profile.provider),
model: facade.model,
configure: configureProfile,
}
}
return configureProfile()
}
export const provider = {
id,
configure,
}
export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras)
export const deepinfra = define(profiles.deepinfra)
export const deepseek = define(profiles.deepseek)
export const fireworks = define(profiles.fireworks)
export const groq = define(profiles.groq)
export const togetherai = define(profiles.togetherai)
+9 -19
View File
@@ -1,10 +1,8 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { Auth } from "../route/auth"
import type { Route, RouteDefaultsInput } from "../route/client" import type { Route, RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import { ProviderPackage } from "../provider-package" import * as OpenAIChat from "../protocols/openai-chat"
import { OpenAIChat } from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses"
import { OpenAIResponses } from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options" export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
@@ -16,15 +14,13 @@ export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, Op
// This provider facade wraps the lower-level Responses and Chat model factories // This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback, // with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
// and default option normalization. // and default option normalization.
type Config = RouteDefaultsInput & export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly queryParams?: Record<string, string> readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
export interface OpenAISettings extends ProviderPackage.Settings {}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
const defaults = (input: Config) => { const defaults = (input: Config) => {
@@ -59,15 +55,9 @@ export const configure = (input: Config = {}) => {
} }
} }
export const model = ProviderPackage.define((modelID, settings: OpenAISettings) => export const provider = configure()
OpenAIResponses.route
.with({ export const model = provider.model
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey), export const responses = provider.responses
endpoint: { baseURL: settings.baseURL }, export const responsesWebSocket = provider.responsesWebSocket
headers: settings.headers, export const chat = provider.chat
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
http: { body: settings.body },
limits: settings.limits,
})
.model({ id: modelID }),
)
@@ -1,22 +0,0 @@
import { Auth } from "../../route/auth"
import { ProviderPackage } from "../../provider-package"
import { OpenAIResponses } from "../../protocols/openai-responses"
export interface OpenAICodexSettings extends ProviderPackage.Settings {
readonly accountID?: string
}
export const model = ProviderPackage.define((modelID, settings: OpenAICodexSettings) =>
OpenAIResponses.route
.with({
auth: (settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey)).andThen(
settings.accountID === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": settings.accountID }),
),
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
headers: settings.headers,
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
http: { body: settings.body },
limits: settings.limits,
})
.model({ id: modelID }),
)
+28 -29
View File
@@ -42,41 +42,40 @@ export type MediaPart = Schema.Schema.Type<typeof MediaPart>
export { ToolContent, ToolFileContent, ToolTextContent } export { ToolContent, ToolFileContent, ToolTextContent }
// Standalone schema const so the derived type does not participate in the
// Object.assign self-reference below; keeps checking order-independent.
const toolResultValueSchema = Schema.Union([
Schema.Struct({
type: Schema.Literal("json"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("text"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("error"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(ToolContent),
}),
]).annotate({ identifier: "LLM.ToolResult" })
const isToolResultValue = (value: unknown): value is ToolResultValue => const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) && isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") && (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value "value" in value
export const ToolResultValue = Object.assign(toolResultValueSchema, { export const ToolResultValue = Object.assign(
is: isToolResultValue, Schema.Union([
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => { Schema.Struct({
if (isToolResultValue(value)) return value type: Schema.Literal("json"),
if (type === "content") return { type, value: Array.isArray(value) ? value : [] } value: Schema.Unknown,
return { type, value } }),
Schema.Struct({
type: Schema.Literal("text"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("error"),
value: Schema.Unknown,
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(ToolContent),
}),
]).annotate({ identifier: "LLM.ToolResult" }),
{
is: isToolResultValue,
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
if (isToolResultValue(value)) return value
if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
return { type, value }
},
}, },
}) )
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema> export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
export interface ToolOutput { export interface ToolOutput {
readonly structured: unknown readonly structured: unknown
+5 -8
View File
@@ -67,6 +67,7 @@ requiredAuthModel("custom-model", {})
// @ts-expect-error auth is an override, so apiKey cannot be supplied with it. // @ts-expect-error auth is an override, so apiKey cannot be supplied with it.
requiredAuthModel("custom-model", { apiKey: "key", auth }) requiredAuthModel("custom-model", { apiKey: "key", auth })
OpenAI.responses("gpt-4.1-mini")
OpenAI.configure({}).responses("gpt-4.1-mini") OpenAI.configure({}).responses("gpt-4.1-mini")
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini") OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini") OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
@@ -98,6 +99,7 @@ OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth. // @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") }) OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
OpenAI.chat("gpt-4.1-mini")
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini") OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini") OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini") OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini")
@@ -153,14 +155,9 @@ XAI.configure({ apiKey: "xai-key" }).responses("grok-4", {})
// @ts-expect-error xAI Chat selectors only accept model ids. // @ts-expect-error xAI Chat selectors only accept model ids.
XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {}) XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {})
OpenAICompatible.configure({ OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
apiKey: "deepseek-key", // @ts-expect-error OpenAI-compatible family selectors only accept model ids.
provider: "deepseek", OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
baseURL: "https://api.deepseek.com/v1",
}).model("deepseek-chat")
OpenAICompatible.configure({ apiKey: "deepseek-key", provider: "deepseek", baseURL: "https://api.deepseek.com/v1" })
// @ts-expect-error OpenAI-compatible model selectors only accept model ids.
.model("deepseek-chat", {})
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama") Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids. // @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
+5 -10
View File
@@ -1,13 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { LLM, LLMClient, Provider, ProviderPackage } from "@opencode-ai/llm" import { LLM, LLMClient, Provider } from "@opencode-ai/llm"
import { Route, Protocol } from "@opencode-ai/llm/route" import { Route, Protocol } from "@opencode-ai/llm/route"
import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider" import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider"
import { ProviderPackage as ProviderPackageSubpath } from "@opencode-ai/llm/provider-package"
import { import {
CloudflareAIGateway, CloudflareAIGateway,
CloudflareWorkersAI, CloudflareWorkersAI,
OpenAI, OpenAI,
OpenAICodex,
OpenAICompatible, OpenAICompatible,
OpenRouter, OpenRouter,
XAI, XAI,
@@ -23,8 +21,6 @@ describe("public exports", () => {
expect(LLMClient.layer).toBeDefined() expect(LLMClient.layer).toBeDefined()
expect(Provider.make).toBeFunction() expect(Provider.make).toBeFunction()
expect(ProviderSubpath.make).toBe(Provider.make) expect(ProviderSubpath.make).toBe(Provider.make)
expect(ProviderPackage.define).toBeFunction()
expect(ProviderPackageSubpath.define).toBe(ProviderPackage.define)
}) })
test("route barrel exposes route-authoring APIs", () => { test("route barrel exposes route-authoring APIs", () => {
@@ -34,12 +30,11 @@ describe("public exports", () => {
test("provider barrels expose user-facing facades", () => { test("provider barrels expose user-facing facades", () => {
expect(OpenAI.model).toBeFunction() expect(OpenAI.model).toBeFunction()
expect(OpenAI.provider.model).toBe(OpenAI.model)
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction() expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
expect(OpenAICodex.model).toBeFunction() expect(OpenAICompatible.deepseek.model).toBeFunction()
expect(OpenAICompatible.model).toBeFunction()
expect(
OpenAICompatible.configure({ baseURL: "https://api.compatible.test/v1", apiKey: "fixture" }).model,
).toBeFunction()
expect(CloudflareAIGateway.configure).toBeFunction() expect(CloudflareAIGateway.configure).toBeFunction()
expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction() expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).toBeFunction()
expect(CloudflareWorkersAI.configure).toBeFunction() expect(CloudflareWorkersAI.configure).toBeFunction()
@@ -39,21 +39,17 @@ const cloudflareAIGatewayWorkers = cloudflareAIGateway.model("workers-ai/@cf/met
const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b") const cloudflareAIGatewayWorkersTools = cloudflareAIGateway.model("workers-ai/@cf/openai/gpt-oss-20b")
const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct") const cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b") const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
const deepseek = OpenAICompatible.configure({ const deepseek = OpenAICompatible.deepseek
apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture", .configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
provider: "deepseek", .model("deepseek-chat")
baseURL: "https://api.deepseek.com/v1", const together = OpenAICompatible.togetherai
}).model("deepseek-chat") .configure({
const together = OpenAICompatible.configure({ apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture", })
provider: "togetherai", .model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
baseURL: "https://api.together.xyz/v1", const groq = OpenAICompatible.groq
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo") .configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
const groq = OpenAICompatible.configure({ .model("llama-3.3-70b-versatile")
apiKey: process.env.GROQ_API_KEY ?? "fixture",
provider: "groq",
baseURL: "https://api.groq.com/openai/v1",
}).model("llama-3.3-70b-versatile")
const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" }) const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
const openrouter = openRouter.model("openai/gpt-4o-mini") const openrouter = openRouter.model("openai/gpt-4o-mini")
const openrouterGpt55 = openRouter.model("openai/gpt-5.5") const openrouterGpt55 = openRouter.model("openai/gpt-5.5")
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolCallPart } from "../../src" import { LLM, Message, ToolCallPart } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http" import { dynamicResponse } from "../lib/http"
@@ -39,6 +40,15 @@ const usageChunk = (usage: object) => ({
usage, usage,
}) })
const providerFamilies = [
["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"],
["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"],
["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"],
["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"],
["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"],
["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"],
] as const
describe("OpenAI-compatible Chat route", () => { describe("OpenAI-compatible Chat route", () => {
it.effect("prepares generic Chat target", () => it.effect("prepares generic Chat target", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -80,6 +90,41 @@ describe("OpenAI-compatible Chat route", () => {
}), }),
) )
it.effect("provides model helpers for compatible provider families", () =>
Effect.gen(function* () {
expect(
providerFamilies.map(([provider, family]) => {
const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
return {
id: String(model.id),
provider: String(model.provider),
route: model.route.id,
baseURL: model.route.endpoint.baseURL,
}
}),
).toEqual(
providerFamilies.map(([provider, _, baseURL]) => ({
id: `${provider}-model`,
provider,
route: "openai-compatible-chat",
baseURL,
})),
)
const custom = OpenAICompatible.deepseek
.configure({
apiKey: "test-key",
baseURL: "https://custom.deepseek.test/v1",
})
.model("deepseek-chat")
expect(custom).toMatchObject({
provider: "deepseek",
route: { id: "openai-compatible-chat" },
})
expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
}),
)
it.effect("matches AI SDK compatible basic request body fixture", () => it.effect("matches AI SDK compatible basic request body fixture", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(request) const prepared = yield* LLMClient.prepare(request)
@@ -1,156 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMClient } from "../../src"
import { Anthropic, OpenAI, OpenAICodex, OpenAICompatible } from "../../src/providers"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
const JsonRecord = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decodeJsonRecord = Schema.decodeUnknownSync(JsonRecord)
const requestFor = (model: ReturnType<typeof OpenAI.model>) =>
LLM.request({
model,
prompt: "Say hello.",
cache: "none",
})
describe("provider package contract", () => {
it.effect("builds OpenAI Responses models from flat settings", () =>
Effect.gen(function* () {
yield* LLMClient.generate(
requestFor(
OpenAI.model("gpt-x", {
apiKey: "sk-test",
headers: { "x-package": "openai" },
body: { metadata: { source: "package" } },
providerOptions: { store: true },
limits: { context: 100, output: 20 },
}),
),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
const body = decodeJsonRecord(input.text)
expect(web.headers.get("authorization")).toBe("Bearer sk-test")
expect(web.headers.get("x-package")).toBe("openai")
expect(body).toMatchObject({
model: "gpt-x",
metadata: { source: "package" },
store: true,
stream: true,
})
expect(body).not.toHaveProperty("apiKey")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
}),
)
it.effect("builds Codex models against the ChatGPT Codex endpoint with optional account header", () =>
Effect.gen(function* () {
yield* LLMClient.generate(
requestFor(OpenAICodex.model("gpt-5-codex", { apiKey: "oauth-token", accountID: "account-123" })),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url.startsWith("https://chatgpt.com/backend-api/codex")).toBe(true)
expect(web.headers.get("authorization")).toBe("Bearer oauth-token")
expect(web.headers.get("chatgpt-account-id")).toBe("account-123")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
yield* LLMClient.generate(requestFor(OpenAICodex.model("gpt-5-codex", { apiKey: "oauth-token" }))).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url.startsWith("https://chatgpt.com/backend-api/codex")).toBe(true)
expect(web.headers.get("authorization")).toBe("Bearer oauth-token")
expect(web.headers.get("chatgpt-account-id")).toBeNull()
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
}),
)
it.effect("builds Anthropic Messages models with x-api-key auth", () =>
Effect.gen(function* () {
yield* LLMClient.generate(requestFor(Anthropic.model("claude-x", { apiKey: "anthropic-key" }))).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
const body = decodeJsonRecord(input.text)
expect(web.headers.get("x-api-key")).toBe("anthropic-key")
expect(body).toMatchObject({ model: "claude-x", stream: true })
return input.respond(
sseEvents({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
)
}),
)
it.effect("builds OpenAI-compatible Chat models from flat settings", () =>
Effect.gen(function* () {
yield* LLMClient.generate(
requestFor(
OpenAICompatible.model("compatible-x", {
apiKey: "compatible-key",
baseURL: "https://api.compatible.test/v1",
body: { user: "provider-package" },
providerOptions: { serviceTier: "priority" },
}),
),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
const body = decodeJsonRecord(input.text)
expect(web.url).toBe("https://api.compatible.test/v1/chat/completions")
expect(web.headers.get("authorization")).toBe("Bearer compatible-key")
expect(body).toMatchObject({
model: "compatible-x",
user: "provider-package",
stream: true,
})
expect(body).not.toHaveProperty("apiKey")
return input.respond(sseEvents({ choices: [{ delta: {}, finish_reason: "stop" }], usage: null }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
}),
)
})
@@ -43,6 +43,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"), experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"), experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"), experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
+25 -12
View File
@@ -1,6 +1,7 @@
import path from "node:path" import path from "node:path"
import { pathToFileURL } from "node:url" import { pathToFileURL } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { type Tool } from "ai"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
@@ -153,19 +154,17 @@ export interface ServerInstructions {
tools: string[] tools: string[]
} }
/** An MCP tool in its native shape; consumers adapt it to their own tool format. */
export interface McpTool {
/** Shared cached definition; consumers must copy rather than mutate it. */
readonly def: MCPToolDef
readonly client: MCPClient
readonly timeout?: number
}
export interface Interface { export interface Interface {
readonly status: () => Effect.Effect<Record<string, Status>> readonly status: () => Effect.Effect<Record<string, Status>>
readonly clients: () => Effect.Effect<Record<string, MCPClient>> readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]> readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, McpTool>> readonly tools: () => Effect.Effect<Record<string, Tool>>
/**
* Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`).
* Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code
* mode uses to render tool signatures (including return types) to the model.
*/
readonly defs: () => Effect.Effect<Record<string, MCPToolDef>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>> readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>> readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: ( readonly resourceTemplates: (
@@ -663,7 +662,7 @@ const layer = Layer.effect(
} }
const tools = Effect.fn("MCP.tools")(function* () { const tools = Effect.fn("MCP.tools")(function* () {
const result: Record<string, McpTool> = {} const result: Record<string, Tool> = {}
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const cfg = yield* cfgSvc.get() const cfg = yield* cfgSvc.get()
@@ -679,8 +678,21 @@ const layer = Layer.effect(
continue continue
} }
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
for (const def of listed) { for (const mcpTool of listed) {
result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout } const key = McpCatalog.toolName(clientName, mcpTool.name)
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
}
}
return result
})
const defs = Effect.fn("MCP.defs")(function* () {
const result: Record<string, MCPToolDef> = {}
const s = yield* InstanceState.get(state)
for (const [clientName, listed] of Object.entries(s.defs)) {
if (s.status[clientName]?.status !== "connected") continue
for (const mcpTool of listed) {
result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool
} }
} }
return result return result
@@ -988,6 +1000,7 @@ const layer = Layer.effect(
clients, clients,
instructions, instructions,
tools, tools,
defs,
prompts, prompts,
resources, resources,
resourceTemplates, resourceTemplates,
+65
View File
@@ -0,0 +1,65 @@
import type { ToolExecutionOptions } from "ai"
import { Effect } from "effect"
import type { Plugin } from "@/plugin"
import type { Tool } from "@/tool/tool"
/**
* The shared middle of every raw MCP tool invocation: plugin `tool.execute.before`
* hook permission ask dispatch through the ai-sdk tool's execute inside the
* `Tool.execute` tracing span plugin `tool.execute.after` hook. Used by both the
* legacy per-tool registration in `SessionTools.resolve` and code-mode child calls,
* so MCP tools execute identically on either path.
*
* Returns the RAW result the ai-sdk execute resolved with callers own their
* shaping edge (model-facing text/attachment shaping + truncation on the legacy
* path, `toSandboxResult` for code-mode child calls). The after hook fires here
* with that same raw result, which is exactly what the legacy loop always passed
* (the raw MCP result, not the shaped `{title, output, metadata}`), so the hook
* payload cannot drift between callers.
*
* `callID` is the hook/span identity an opaque string nothing parses. Legacy
* passes the ai-sdk `toolCallId`; code-mode child calls pass a synthetic
* `${parentCallID}/${n}`. `options.toolCallId` is what the ai-sdk execute sees and
* stays each caller's existing value. Failure semantics belong to the caller: hook
* failures, permission denials, and tool failures all propagate the legacy path
* lets them fail the tool call as before; code mode converts them into catchable
* in-program tool errors at its edge.
*/
export const invoke = Effect.fn("McpInvoke.invoke")(function* <R>(input: {
plugin: Plugin.Interface
key: string
execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike<R>
args: any
callID: string
options: ToolExecutionOptions
sessionID: string
messageID: string
ask: Tool.Context["ask"]
}) {
yield* input.plugin.trigger(
"tool.execute.before",
{ tool: input.key, sessionID: input.sessionID, callID: input.callID },
{ args: input.args },
)
const result: R = yield* Effect.gen(function* () {
yield* input.ask({ permission: input.key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => Promise.resolve(input.execute(input.args, input.options)))
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": input.key,
"tool.call_id": input.callID,
"session.id": input.sessionID,
"message.id": input.messageID,
},
}),
)
yield* input.plugin.trigger(
"tool.execute.after",
{ tool: input.key, sessionID: input.sessionID, callID: input.callID, args: input.args },
result,
)
return result
})
export * as McpInvoke from "./invoke"
@@ -213,6 +213,13 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
) )
} }
/**
* The shared tool-visibility predicate: drop every tool a hard deny hides
* ({@link disabled} semantics a matching `deny` rule with pattern `"*"`).
* Ask-level rules leave a tool fully visible and callable (it prompts at call
* time). Used both when preparing the LLM tool list (request prep) and when
* building/dispatching the code-mode MCP catalog, so the two cannot drift.
*/
export function visibleTools<T>(tools: Record<string, T>, ruleset: PermissionV1.Ruleset): Record<string, T> { export function visibleTools<T>(tools: Record<string, T>, ruleset: PermissionV1.Ruleset): Record<string, T> {
const hidden = disabled(Object.keys(tools), ruleset) const hidden = disabled(Object.keys(tools), ruleset)
return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name))) return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name)))
@@ -292,6 +292,8 @@ export function createRoutes(
HttpServer.layerServices, HttpServer.layerServices,
]), ]),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(Observability.layer),
Layer.provide(sessionLocationLayer), Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer), Layer.provide(locationLayer),
Layer.provide(PtyEnvironment.layer), Layer.provide(PtyEnvironment.layer),
@@ -304,11 +306,6 @@ export function createRoutes(
Layer.provide(locationServiceMapV2), Layer.provide(locationServiceMapV2),
Layer.provide(AppNodeBuilderV1.build(app)), Layer.provide(AppNodeBuilderV1.build(app)),
// Must stay last: layers provided later in this pipe build beneath earlier ones,
// so Observability must come after every service graph. Otherwise eagerly forked
// fibers (e.g. the ModelsDev background refresh) capture Effect's default stdout
// logger and corrupt the TUI (#34730).
Layer.provideMerge(Observability.layer),
) )
} }
+3 -3
View File
@@ -206,11 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
}) })
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) { function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled( const visible = Permission.visibleTools(
Object.keys(input.tools), input.tools,
Permission.merge(input.agent.permission, input.permission ?? []), Permission.merge(input.agent.permission, input.permission ?? []),
) )
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k)) return Record.filter(visible, (_, k) => input.user.tools?.[k] !== false)
} }
export function hasToolCalls(messages: ModelMessage[]): boolean { export function hasToolCalls(messages: ModelMessage[]): boolean {
@@ -192,8 +192,6 @@ const layer = Layer.effect(
status: "error", status: "error",
input: match.part.state.input, input: match.part.state.input,
error: errorMessage(error), error: errorMessage(error),
// Keep metadata streamed while running so failures retain progress detail (e.g. execute's child calls).
metadata: match.part.state.metadata,
time: { start: match.part.state.time.start, end: Date.now() }, time: { start: match.part.state.time.start, end: Date.now() },
}, },
}) })
+2
View File
@@ -1237,6 +1237,8 @@ const layer = Layer.effect(
Effect.provideService(ToolRegistry.Service, registry), Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp), Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate), Effect.provideService(Truncate.Service, truncate),
Effect.provideService(Agent.Service, agents),
Effect.provideService(RuntimeFlags.Service, flags),
) )
if (lastUser.format?.type === "json_schema") { if (lastUser.format?.type === "json_schema") {
+30 -28
View File
@@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog" import { McpInvoke } from "@/mcp/invoke"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Tool } from "@/tool/tool" import { Tool } from "@/tool/tool"
import { ToolJsonSchema } from "@/tool/json-schema" import { ToolJsonSchema } from "@/tool/json-schema"
@@ -22,6 +22,7 @@ import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { RuntimeFlags } from "@/effect/runtime-flags"
const MCP_RESOURCE_TOOLS = { const MCP_RESOURCE_TOOLS = {
list: "list_mcp_resources", list: "list_mcp_resources",
@@ -53,6 +54,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
const truncate = yield* Truncate.Service const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({ const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
sessionID: input.session.id, sessionID: input.session.id,
@@ -87,11 +89,20 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
.pipe(Effect.orDie), .pipe(Effect.orDie),
}) })
for (const item of yield* registry.tools({ const mcpTools = yield* mcp.tools()
// When code mode is enabled and MCP tools are present, the registry exposes them
// through the single code-mode `execute` tool (ToolRegistry.tools), so raw per-MCP
// registration is suppressed via the early return below. Code mode is experimental
// and off by default.
const codeMode = flags.experimentalCodeMode && Object.keys(mcpTools).length > 0
const registryTools = yield* registry.tools({
modelID: ModelV2.ID.make(input.model.api.id), modelID: ModelV2.ID.make(input.model.api.id),
providerID: input.model.providerID, providerID: input.model.providerID,
agent: input.agent, agent: input.agent,
})) { permission: input.session.permission,
})
for (const item of registryTools) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({ tools[item.id] = tool({
description: item.description, description: item.description,
@@ -382,8 +393,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
}) })
} }
for (const [key, entry] of Object.entries(yield* mcp.tools())) { if (codeMode) return tools
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
for (const [key, item] of Object.entries(mcpTools)) {
const execute = item.execute const execute = item.execute
if (!execute) continue if (!execute) continue
@@ -394,29 +406,19 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
run.promise( run.promise(
Effect.gen(function* () { Effect.gen(function* () {
const ctx = context(args, opts) const ctx = context(args, opts)
yield* plugin.trigger( // Shared MCP middle (before hook → permission ask → Tool.execute span →
"tool.execute.before", // dispatch → after hook); this caller keeps the model-facing shaping edge below.
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* McpInvoke.invoke({
{ args }, plugin,
) key,
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () { execute,
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) args,
return yield* Effect.promise(() => execute(args, opts)) callID: opts.toolCallId,
}).pipe( options: opts,
Effect.withSpan("Tool.execute", { sessionID: ctx.sessionID,
attributes: { messageID: input.processor.message.id,
"tool.name": key, ask: ctx.ask,
"tool.call_id": opts.toolCallId, })
"session.id": ctx.sessionID,
"message.id": input.processor.message.id,
},
}),
)
yield* plugin.trigger(
"tool.execute.after",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
result,
)
const textParts: string[] = [] const textParts: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = [] const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
+251 -147
View File
@@ -1,5 +1,6 @@
import * as Tool from "./tool" import * as Tool from "./tool"
import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js" import type { Tool as AITool } from "ai"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Schema } from "effect" import { Cause, Effect, Schema } from "effect"
import { import {
CodeMode, CodeMode,
@@ -11,6 +12,7 @@ import {
} from "@opencode-ai/codemode" } from "@opencode-ai/codemode"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog" import { McpCatalog } from "@/mcp/catalog"
import { McpInvoke } from "@/mcp/invoke"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { Permission } from "@/permission" import { Permission } from "@/permission"
@@ -18,6 +20,18 @@ import { Plugin } from "@/plugin"
export const CODE_MODE_TOOL = "execute" export const CODE_MODE_TOOL = "execute"
// OpenCode sets NO execution limits: no timeout, no tool-call cap, and no CodeMode output
// truncation. Cancelling the tool call aborts `ctx.abort`, which wins the race below and
// interrupts the execution fiber — structured concurrency takes the program and its
// in-flight child calls down with it; every child call is permission-gated anyway. Output
// bounding is OpenCode's native tool-output truncation (Tool.define's shared wrapper),
// which applies to `execute` like any other tool and dumps the full output to a file when
// it triggers.
// The static base description. The full usage guide and the grouped, permission-filtered
// tool catalog are appended per agent by the registry (`describeCodeMode`, the same
// composition point `describeTask` uses), so `plugin.trigger("tool.definition")` sees this
// base first, exactly like the task tool.
const DESCRIPTION = [ const DESCRIPTION = [
"Execute a JavaScript/TypeScript program that orchestrates the connected MCP tools inside a confined runtime.", "Execute a JavaScript/TypeScript program that orchestrates the connected MCP tools inside a confined runtime.",
"The full usage guide and the catalog of available tools follow below.", "The full usage guide and the catalog of available tools follow below.",
@@ -33,51 +47,119 @@ export const Parameters = Schema.Struct({
}), }),
}) })
type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> } /** One child tool call, surfaced live so the UI can render a per-call line that
* updates as the program runs. `tool` is the dotted path (e.g. `github.create_issue`). */
export type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
type Metadata = { type Metadata = {
toolCalls: CallEntry[] toolCalls: CallEntry[]
error?: boolean error?: boolean
} }
type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number] /**
* A tool-result attachment: identical to a session `FilePart` (minus the ids) and
* carrying the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into
* `Tool.ExecuteResult.attachments`. Attachments never enter the sandbox media stripped
* from child tool results is accumulated host-side and returned on the outer `execute`
* result, where the existing attachment plumbing turns it into visible images/files.
*/
export type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]
type CatalogEntry = { /** One MCP tool in the grouped catalog: the flat `server_tool` key split into its
* namespace (`server`) and local name, with the raw JSON Schemas used for rendering. */
export type CatalogEntry = {
path: string path: string
key: string key: string
server: string server: string
local: string local: string
tool: MCP.McpTool description: string
tool: AITool
inputSchema: JsonSchema
outputSchema?: JsonSchema
} }
function groupByServer(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): Map<string, CatalogEntry[]> { /** Render-only cast: MCP definitions carry JSON Schema documents already. */
const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema
/** The input schema for entries without a cached MCP definition, recovered from the
* ai-sdk tool when possible so signatures stay informative. */
function fallbackInputSchema(tool: AITool): JsonSchema {
const schema = (tool.inputSchema as { jsonSchema?: unknown } | undefined)?.jsonSchema
if (schema && typeof schema === "object") return toJsonSchema(schema)
return { type: "object", properties: {} }
}
/**
* Group the flat `server_tool` catalog into per-server namespaces. `servers` are
* the sanitized MCP client names; the longest matching prefix wins so a server
* named `a_b` beats `a` for the key `a_b_tool`. `mcpDefs` carries the raw MCP
* definitions (keyed identically) so each entry retains its original
* `inputSchema`/`outputSchema` for signature rendering.
*/
export function groupByServer(
mcpTools: Record<string, AITool>,
servers: readonly string[],
mcpDefs: Record<string, MCPToolDef> = {},
): Map<string, CatalogEntry[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length) const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, CatalogEntry[]>() const groups = new Map<string, CatalogEntry[]>()
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) { for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
const server = const server = byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
const def = mcpDefs[key]
const entry: CatalogEntry = { const entry: CatalogEntry = {
path: `${server}.${local}`, path: `${server}.${local}`,
key, key,
server, server,
local, local,
description: mcpTools[key]!.description ?? def?.description ?? "",
tool: mcpTools[key]!, tool: mcpTools[key]!,
inputSchema: def?.inputSchema ? toJsonSchema(def.inputSchema) : fallbackInputSchema(mcpTools[key]!),
...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}),
} }
groups.set(server, [...(groups.get(server) ?? []), entry]) groups.set(server, [...(groups.get(server) ?? []), entry])
} }
return groups return groups
} }
export function describeCatalog(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): string { /** The executable catalog for a (already permission-filtered) MCP tool set: grouped
* entries, minus any without an ai-sdk execute function. */
export function buildCatalog(
mcpTools: Record<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
servers: readonly string[],
): CatalogEntry[] {
return [...groupByServer(mcpTools, servers, mcpDefs).values()].flat().filter((entry) => entry.tool.execute !== undefined)
}
/**
* The model-facing usage guide plus grouped catalog for the given MCP tool set: the
* CodeMode instructions for this tool tree (syntax guide + tool signatures, or the
* namespace overview + search for large catalogs). Callers pass an already
* permission-filtered tool set hard-denied tools never enter the catalog. The preview
* tree's runs are placeholders rendering never invokes them.
*/
export function catalogInstructions(
mcpTools: Record<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
servers: readonly string[],
): string {
const catalog = buildCatalog(mcpTools, mcpDefs, servers)
return CodeMode.make({ return CodeMode.make({
tools: toolTree( tools: toolTree(catalog, () => () => Effect.fail(toolError("Tool preview is not executable."))),
[...groupByServer(mcpTools, servers).values()].flat(),
() => () => Effect.fail(toolError("Tool preview is not executable.")),
),
}).instructions() }).instructions()
} }
function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return
if (typeof input === "object" && !Array.isArray(input)) {
const value = input as Record<string, unknown>
if (Object.keys(value).length > 0) return value
return
}
return { input }
}
const lastSegment = (uri: string) => { const lastSegment = (uri: string) => {
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "") const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1) const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
@@ -86,7 +168,24 @@ const lastSegment = (uri: string) => {
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}` const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
function projectMcpResult(result: CallToolResult, collect: (attachment: Attachment) => void): unknown { /** The stand-in payload for a media-only tool result, so the program knows the call
* succeeded even though the media itself never enters the sandbox. */
const mediaMarker = (files: number, images: number) => {
const noun = files === images ? "image" : "file"
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
}
/**
* Reduce a raw MCP tool result to the value the sandbox sees. Structured content is
* preferred; otherwise text blocks are joined. Media blocks (image/audio/resource
* blob/resource_link) NEVER enter the sandbox: they are stripped into `collect`, the
* per-execution attachment accumulator, and a tool that returned ONLY media yields a
* small text marker instead. Lenient never throws on unexpected shapes.
*/
export function toSandboxResult(raw: unknown, collect: (attachment: Attachment) => void): unknown {
if (raw === null || typeof raw !== "object") return raw
const record = raw as { structuredContent?: unknown; content?: unknown }
const content = Array.isArray(record.content) ? record.content : []
const text: string[] = [] const text: string[] = []
let files = 0 let files = 0
let images = 0 let images = 0
@@ -95,109 +194,105 @@ function projectMcpResult(result: CallToolResult, collect: (attachment: Attachme
if (attachment.mime.startsWith("image/")) images += 1 if (attachment.mime.startsWith("image/")) images += 1
collect(attachment) collect(attachment)
} }
for (const block of result.content) { for (const item of content) {
if (!item || typeof item !== "object") continue
const block = item as Record<string, unknown>
switch (block.type) { switch (block.type) {
case "text": case "text":
text.push(block.text) if (typeof block.text === "string") text.push(block.text)
break break
case "image": case "image":
case "audio": case "audio":
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) }) if (typeof block.data === "string" && typeof block.mimeType === "string") {
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
}
break break
case "resource": { case "resource": {
if ("text" in block.resource) { const res = block.resource as Record<string, unknown> | undefined
text.push(block.resource.text) if (res && typeof res === "object") {
break const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream"
const uri = typeof res.uri === "string" ? res.uri : undefined
if (typeof res.blob === "string") {
push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined })
} else if (typeof res.text === "string") {
text.push(res.text)
}
} }
const mime = block.resource.mimeType ?? "application/octet-stream"
push({ type: "file", mime, url: dataUrl(mime, block.resource.blob), filename: lastSegment(block.resource.uri) })
break break
} }
case "resource_link": case "resource_link":
// A link is a reference, not fetchable media; hand it to the program instead of the attachment channel. if (typeof block.uri === "string") {
text.push(`${block.name}: ${block.uri}`) push({
type: "file",
mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream",
url: block.uri,
filename: typeof block.name === "string" ? block.name : lastSegment(block.uri),
})
}
break break
} }
} }
if (result.structuredContent !== undefined && result.structuredContent !== null) return result.structuredContent if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent
if (text.length > 0) return text.join("\n") if (text.length > 0) return text.join("\n")
if (files > 0) { if (files > 0) return mediaMarker(files, images)
const noun = files === images ? "image" : "file" if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]` return raw
}
/**
* Append captured `console.*` output to the model-facing text as a trailing `Logs:` section,
* so a program's diagnostics ride back alongside its result on success AND on error.
* Returns the text unchanged when nothing was logged. This is the sandbox's only
* stdout-like channel it goes to the model, not the user.
*/
export function withLogs(output: string, logs: ReadonlyArray<string> = []): string {
if (logs.length === 0) return output
const section = "Logs:\n" + logs.join("\n")
return output.length > 0 ? `${output}\n\n${section}` : section
}
/** Coerce the program's return value to model-facing text without ever failing on shape. */
export function formatValue(value: unknown): string {
if (typeof value === "string") return value
if (value === undefined) return "undefined"
try {
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
} }
return null
} }
type Run = (input: unknown) => Effect.Effect<unknown, unknown> type Run = (input: unknown) => Effect.Effect<unknown, unknown>
/** Build the `tools.<server>.<tool>` tree CodeMode executes against, one
* `Tool.make` definition per MCP tool with its render-only JSON Schemas. */
function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) { function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) {
const tree: Record<string, Record<string, ToolDefinition>> = {} const tree: Record<string, Record<string, ToolDefinition>> = {}
for (const entry of catalog) { for (const entry of catalog) {
const namespace = (tree[entry.server] ??= {}) const namespace = (tree[entry.server] ??= {})
namespace[entry.local] = SandboxTool.make({ namespace[entry.local] = SandboxTool.make({
description: entry.tool.def.description ?? "", description: entry.description,
input: entry.tool.def.inputSchema as JsonSchema, input: entry.inputSchema,
output: entry.tool.def.outputSchema as JsonSchema | undefined, output: entry.outputSchema,
run: run(entry), run: run(entry),
}) })
} }
return tree return tree
} }
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: { /** Failures inside a child call plugin hook failures, permission denials, and tool
plugin: Plugin.Interface * failures alike become safe, catchable in-program errors via toolError, so a
entry: CatalogEntry * program can try/catch one call without the whole execution dying. Interruption
args: Record<string, unknown> * (user cancel) keeps propagating as interruption. */
callID: string const toCatchable = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
ctx: Tool.Context effect.pipe(
}) { Effect.catchCause((cause) => {
yield* input.plugin.trigger( if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
"tool.execute.before", const error = Cause.squash(cause)
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID }, return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error))
{ args: input.args },
)
const result: CallToolResult = yield* Effect.gen(function* () {
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
return yield* Effect.promise(async () => {
const raw = await input.entry.tool.client.callTool(
{ name: input.entry.tool.def.name, arguments: input.args },
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
signal: input.ctx.abort,
timeout: input.entry.tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (raw.isError)
throw new Error(
raw.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return raw
})
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": input.entry.key,
"tool.call_id": input.callID,
"session.id": input.ctx.sessionID,
"message.id": input.ctx.messageID,
},
}), }),
) )
yield* input.plugin.trigger(
"tool.execute.after",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID, args: input.args },
result,
)
return result
})
export const CodeModeTool = Tool.define( export const CodeModeTool = Tool.define(
CODE_MODE_TOOL, CODE_MODE_TOOL,
@@ -211,6 +306,8 @@ export const CodeModeTool = Tool.define(
description: DESCRIPTION, description: DESCRIPTION,
parameters: Parameters, parameters: Parameters,
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
// Already cancelled: don't start the program at all. (The mid-flight case is the
// race below; racing alone would still let the program run its first steps.)
if (ctx.abort.aborted) { if (ctx.abort.aborted) {
return { return {
title: CODE_MODE_TOOL, title: CODE_MODE_TOOL,
@@ -218,35 +315,53 @@ export const CodeModeTool = Tool.define(
output: "Execution cancelled.", output: "Execution cancelled.",
} satisfies Tool.ExecuteResult<Metadata> } satisfies Tool.ExecuteResult<Metadata>
} }
// A fresh MCP snapshot per execution, so the runtime tracks live tool-list
// changes, filtered with the same merged agent+session ruleset that gates
// `ctx.ask` (see SessionTools.context). A hard-denied tool never enters the
// tree, so it is not dispatchable even if the model guesses its name — the
// program gets the normal unknown-tool diagnostic, not a permission error.
const agent = yield* agents.get(ctx.agent) const agent = yield* agents.get(ctx.agent)
const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie) const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie)
const ruleset = Permission.merge(agent.permission, session.permission ?? []) const ruleset = Permission.merge(agent.permission, session.permission ?? [])
const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset) const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset)
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize) const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
const catalog = [...groupByServer(mcpTools, servers).values()].flat() const catalog = buildCatalog(mcpTools, yield* mcp.defs(), servers)
const calls: CallEntry[] = [] const calls: CallEntry[] = []
// Media stripped from child tool results accumulates here for the life of the
// call; the bytes never enter the sandbox (see toSandboxResult).
const attachments: Attachment[] = [] const attachments: Attachment[] = []
const publish = () => const collect = (attachment: Attachment) => void attachments.push(attachment)
ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) // Stream the current call list to the UI. Sent on every status change so the
// tool part shows each child call appearing and resolving while the program runs.
const publish = () => ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } })
// One CodeMode tool per MCP tool, running the same shared middle as legacy
// per-tool registration (McpInvoke.invoke: plugin before hook → permission
// ask → Tool.execute span → dispatch through the ai-sdk wrapper, which owns
// callTool timeouts/progress and turns an MCP isError into a thrown Error →
// plugin after hook), so plugins observe child calls too. Each child gets a
// synthetic hook/span callID `${parentCallID}/${n}` (per-execution counter,
// opaque — nothing parses it); the ai-sdk toolCallId is unchanged. Failures —
// hook, denial, or tool — fail only that child call as a safe, catchable
// in-program error (toCatchable); the raw result is then shaped for the sandbox.
let childCalls = 0 let childCalls = 0
const callTool = (entry: CatalogEntry) => (input: unknown) => const callTool = (entry: CatalogEntry) => (input: unknown) =>
Effect.gen(function* () { toCatchable(
childCalls += 1 Effect.gen(function* () {
const result = yield* invokeChildTool({ childCalls += 1
plugin, const raw = yield* McpInvoke.invoke({
entry, plugin,
args: (input ?? {}) as Record<string, unknown>, key: entry.key,
callID: `${ctx.callID ?? entry.key}/${childCalls}`, execute: entry.tool.execute!,
ctx, args: input ?? {},
}) callID: `${ctx.callID ?? entry.key}/${childCalls}`,
return projectMcpResult(result, (attachment: Attachment) => void attachments.push(attachment)) options: { toolCallId: ctx.callID ?? entry.key, abortSignal: ctx.abort, messages: [] },
}).pipe( sessionID: ctx.sessionID,
Effect.catchCause((cause) => { messageID: ctx.messageID,
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt ask: ctx.ask,
const error = Cause.squash(cause) })
return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error)) return toSandboxResult(raw, collect)
}), }),
) )
@@ -254,14 +369,7 @@ export const CodeModeTool = Tool.define(
tools: toolTree(catalog, callTool), tools: toolTree(catalog, callTool),
onToolCallStart: ({ index, name, input }) => onToolCallStart: ({ index, name, input }) =>
Effect.suspend(() => { Effect.suspend(() => {
const shown = (() => { const shown = displayInput(input)
if (input === null || input === undefined) return
if (typeof input === "object" && !Array.isArray(input)) {
const value = input as Record<string, unknown>
return Object.keys(value).length > 0 ? value : undefined
}
return { input }
})()
calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) } calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return publish() return publish()
}), }),
@@ -273,51 +381,47 @@ export const CodeModeTool = Tool.define(
}), }),
}) })
const abort = Effect.callback<void>((resume) => { // The shared tool runner does not wire ctx.abort to fiber interruption (it runs
if (ctx.abort.aborted) return resume(Effect.void) // tools via Effect.runPromise with no abort handling), so without this race the
const handler = () => resume(Effect.void) // program would keep running after the user cancels. The abort signal winning the
ctx.abort.addEventListener("abort", handler, { once: true }) // race interrupts the execution fiber; the cancelled result keeps the runner's
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler)) // post-abort bookkeeping (completeToolCall) on its normal path.
}) const cancelled = Effect.callback<ExecuteResult>((resume) => {
const cancelled = (): ExecuteResult => ({ const onAbort = () =>
ok: false, resume(
error: { kind: "ExecutionFailure", message: "Execution cancelled." }, Effect.succeed<ExecuteResult>({
toolCalls: calls.map((call) => ({ name: call.tool })), ok: false,
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
toolCalls: calls.map((call) => ({ name: call.tool })),
}),
)
if (ctx.abort.aborted) return onAbort()
ctx.abort.addEventListener("abort", onAbort, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", onAbort))
}) })
const result = yield* Effect.raceFirst(runtime.execute(params.code), abort.pipe(Effect.map(cancelled))) const result = yield* Effect.raceFirst(runtime.execute(params.code), cancelled)
const logs = result.logs ?? [] const logs = result.logs ?? []
const withLogs = (text: string) => { const attached = attachments.length > 0 ? { attachments } : {}
if (logs.length === 0) return text
return text.length > 0 ? `${text}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}` if (result.ok) {
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: calls },
output: withLogs(formatValue(result.value), logs),
...attached,
} satisfies Tool.ExecuteResult<Metadata>
} }
// Diagnostics may carry suggestions (e.g. pointing an unknown tool at
if (!result.ok) { // discovery); append the ones the message doesn't already contain.
if (ctx.abort.aborted) { const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: calls, error: true },
output: "Execution cancelled.",
} satisfies Tool.ExecuteResult<Metadata>
}
const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
return yield* Effect.fail(new Error(withLogs([result.error.message, ...hints].join("\n"))))
}
// The interpreter validates returned values as plain JSON, so stringify cannot throw;
// it yields undefined only for a program that returns undefined.
const output =
typeof result.value === "string"
? result.value
: (JSON.stringify(result.value, null, 2) ?? String(result.value))
return { return {
title: CODE_MODE_TOOL, title: CODE_MODE_TOOL,
metadata: { toolCalls: calls }, metadata: { toolCalls: calls, error: true },
output: withLogs(output), output: withLogs([result.error.message, ...hints].join("\n"), logs),
...(attachments.length > 0 ? { attachments } : {}), ...attached,
} satisfies Tool.ExecuteResult<Metadata> } satisfies Tool.ExecuteResult<Metadata>
}, Effect.orDie), }),
} }
return init return init
}), }),
+30 -1
View File
@@ -26,6 +26,9 @@ import { Plugin } from "../plugin"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { WebSearchTool } from "./websearch" import { WebSearchTool } from "./websearch"
import { CodeModeTool, catalogInstructions } from "./code-mode"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { LspTool } from "./lsp" import { LspTool } from "./lsp"
import * as Truncate from "./truncate" import * as Truncate from "./truncate"
import { ApplyPatchTool } from "./apply_patch" import { ApplyPatchTool } from "./apply_patch"
@@ -51,6 +54,7 @@ import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
@@ -74,6 +78,7 @@ export interface Interface {
providerID: ProviderV2.ID providerID: ProviderV2.ID
modelID: ModelV2.ID modelID: ModelV2.ID
agent: Agent.Info agent: Agent.Info
permission?: PermissionV1.Ruleset
}) => Effect.Effect<Tool.Def[]> }) => Effect.Effect<Tool.Def[]>
} }
@@ -87,6 +92,7 @@ const layer = Layer.effect(
const agents = yield* Agent.Service const agents = yield* Agent.Service
const truncate = yield* Truncate.Service const truncate = yield* Truncate.Service
const flags = yield* RuntimeFlags.Service const flags = yield* RuntimeFlags.Service
const mcp = yield* MCP.Service
const invalid = yield* InvalidTool const invalid = yield* InvalidTool
const task = yield* TaskTool const task = yield* TaskTool
@@ -104,6 +110,7 @@ const layer = Layer.effect(
const greptool = yield* GrepTool const greptool = yield* GrepTool
const patchtool = yield* ApplyPatchTool const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool const skilltool = yield* SkillTool
const codemode = yield* CodeModeTool
const agent = yield* Agent.Service const agent = yield* Agent.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
@@ -211,6 +218,7 @@ const layer = Layer.effect(
question: Tool.init(question), question: Tool.init(question),
lsp: Tool.init(lsptool), lsp: Tool.init(lsptool),
plan: Tool.init(plan), plan: Tool.init(plan),
codemode: Tool.init(codemode),
}) })
return { return {
@@ -232,6 +240,7 @@ const layer = Layer.effect(
tool.patch, tool.patch,
...(flags.experimentalLspTool ? [tool.lsp] : []), ...(flags.experimentalLspTool ? [tool.lsp] : []),
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []), ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
...(flags.experimentalCodeMode ? [tool.codemode] : []),
], ],
task: tool.task, task: tool.task,
read: tool.read, read: tool.read,
@@ -263,11 +272,25 @@ const layer = Layer.effect(
return ["Available agent types and the tools they have access to:", description].join("\n") return ["Available agent types and the tools they have access to:", description].join("\n")
}) })
// The grouped MCP-tool catalog appended to the code-mode base description, built
// fresh per turn so it tracks live tool-list changes. Hard-denied tools (the shared
// Permission.visibleTools predicate over the agent's ruleset) never enter the
// catalog, its inlined signatures, or the in-program search index.
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) {
const visible = Permission.visibleTools(yield* mcp.tools(), Permission.merge(agent.permission, permission ?? []))
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
return catalogInstructions(visible, yield* mcp.defs(), servers)
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
// Consulted once before the synchronous filter: code mode registers only when the
// experimental flag is on AND at least one MCP tool is connected.
const mcpToolCount = flags.experimentalCodeMode ? Object.keys(yield* mcp.tools()).length : 0
const filtered = (yield* all()).filter((tool) => { const filtered = (yield* all()).filter((tool) => {
if (tool.id === WebSearchTool.id) { if (tool.id === WebSearchTool.id) {
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
} }
if (tool.id === CodeModeTool.id) return flags.experimentalCodeMode && mcpToolCount > 0
const usePatch = const usePatch =
input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4") input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
@@ -292,7 +315,11 @@ const layer = Layer.effect(
: undefined : undefined
return { return {
id: tool.id, id: tool.id,
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined] description: [
output.description,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent, input.permission) : undefined,
]
.filter(Boolean) .filter(Boolean)
.join("\n"), .join("\n"),
parameters: output.parameters, parameters: output.parameters,
@@ -314,6 +341,7 @@ const layer = Layer.effect(
}), }),
) )
function isZodType(value: unknown): value is z.ZodType { function isZodType(value: unknown): value is z.ZodType {
return typeof value === "object" && value !== null && "_zod" in value return typeof value === "object" && value !== null && "_zod" in value
} }
@@ -406,6 +434,7 @@ export const node = LayerNode.make({
LSP.node, LSP.node,
Instruction.node, Instruction.node,
FSUtil.node, FSUtil.node,
MCP.node,
EventV2Bridge.node, EventV2Bridge.node,
httpClient, httpClient,
CrossSpawnSpawner.node, CrossSpawnSpawner.node,
@@ -117,6 +117,7 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) {
clients: () => Effect.succeed({}), clients: () => Effect.succeed({}),
instructions: () => Effect.succeed(instructions), instructions: () => Effect.succeed(instructions),
tools: () => Effect.succeed({}), tools: () => Effect.succeed({}),
defs: () => Effect.succeed({}),
prompts: () => Effect.succeed({}), prompts: () => Effect.succeed({}),
resources: () => Effect.succeed({}), resources: () => Effect.succeed({}),
resourceTemplates: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}),
@@ -39,6 +39,7 @@ const mcp = Layer.succeed(
clients: () => Effect.succeed({}), clients: () => Effect.succeed({}),
instructions: () => Effect.succeed([]), instructions: () => Effect.succeed([]),
tools: () => Effect.succeed({}), tools: () => Effect.succeed({}),
defs: () => Effect.succeed({}),
prompts: () => Effect.succeed({}), prompts: () => Effect.succeed({}),
resources: () => Effect.succeed({}), resources: () => Effect.succeed({}),
resourceTemplates: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}),
@@ -0,0 +1,164 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { SessionTools } from "@/session/tools"
import { ToolRegistry } from "@/tool/registry"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import * as Truncate from "@/tool/truncate"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { InstanceState } from "@/effect/instance-state"
import { MessageID, SessionID } from "@/session/schema"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import type { Tool as AITool } from "ai"
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
})
// Fake MCP.Service built through the same `convertTool` path the real service uses,
// so the raw registration loop dispatches through a genuine ai-sdk execute. The fake
// client answers every call with a small text result.
function fakeMcpLayer(tools: Record<string, { description: string }>) {
const client = { callTool: async () => ({ content: [{ type: "text", text: "ok" }] }) }
const converted: Record<string, AITool> = Object.fromEntries(
Object.entries(tools).map(([key, def]) => [
key,
McpCatalog.convertTool(
{
name: key,
description: def.description,
inputSchema: { type: "object", properties: {} },
} as any,
client as any,
),
]),
)
return Layer.mock(MCP.Service, {
tools: () => Effect.succeed(converted),
defs: () => Effect.succeed({}),
clients: () => Effect.succeed({ github: { getServerCapabilities: () => undefined } } as any),
})
}
const mcpToolsLayer = fakeMcpLayer({
github_create_issue: { description: "Create an issue" },
github_list_issues: { description: "List issues" },
})
const root = LayerNode.group([ToolRegistry.node, Agent.node, MCP.node, RuntimeFlags.node])
const withCodeMode = testEffect(
LayerNode.compile(root, [
[Config.node, configLayer],
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
[MCP.node, mcpToolsLayer],
]),
)
const withFlagOff = testEffect(
LayerNode.compile(root, [
[Config.node, configLayer],
[RuntimeFlags.node, RuntimeFlags.layer()],
[MCP.node, mcpToolsLayer],
]),
)
// Resolve the session tool record with the smallest honest stand-ins for the pieces
// resolve reads: a fabricated model (only providerID/api.id are consulted by the
// schema transform for these fixtures), a pass-through Truncate, an always-allow
// Permission, and an optionally-observing Plugin.trigger. Registry, MCP, and flags
// come from the compiled instance context.
function resolveTools(trigger?: Plugin.Interface["trigger"]) {
return Effect.gen(function* () {
const agents = yield* Agent.Service
const agent = yield* agents.defaultInfo()
return yield* SessionTools.resolve({
agent,
model: { providerID: "opencode", api: { id: "test" } } as any,
session: { id: SessionID.make("ses_session-tools"), permission: [] } as any,
processor: {
message: { id: MessageID.make("msg_session-tools") } as any,
updateToolCall: () => Effect.succeed(undefined),
completeToolCall: () => Effect.void,
},
bypassAgentCheck: false,
messages: [],
promptOps: {} as any,
})
}).pipe(
Effect.provide(
Layer.mergeAll(
Layer.mock(Permission.Service, { ask: () => Effect.void }),
Layer.mock(Plugin.Service, {
trigger:
trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]),
}),
Layer.mock(Truncate.Service, {
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
}),
),
),
)
}
afterEach(async () => {
await disposeAllInstances()
})
describe("session.tools resolve", () => {
withCodeMode.instance("code mode suppresses raw per-MCP registration behind the execute tool", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
const ids = Object.keys(tools)
expect(ids).toContain("execute")
expect(ids).not.toContain("github_create_issue")
expect(ids).not.toContain("github_list_issues")
}),
)
withFlagOff.instance("without code mode the raw MCP tools register and execute is absent", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
const ids = Object.keys(tools)
expect(ids).not.toContain("execute")
expect(ids).toContain("github_create_issue")
expect(ids).toContain("github_list_issues")
}),
)
withFlagOff.instance("legacy raw MCP execution fires plugin hooks keyed by the ai-sdk toolCallId", () =>
Effect.gen(function* () {
const events: { name: string; input: any; output: any }[] = []
const trigger = ((name: unknown, input: unknown, output: unknown) =>
Effect.sync(() => {
events.push({ name: name as string, input, output })
return output
})) as Plugin.Interface["trigger"]
const tools = yield* resolveTools(trigger)
const raw = tools["github_create_issue"]!
const result = yield* Effect.promise(() =>
Promise.resolve(
raw.execute!(
{ title: "x" },
{ toolCallId: "call_legacy", abortSignal: new AbortController().signal, messages: [] },
),
),
)
expect((result as any).output).toBe("ok")
expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
["tool.execute.before", "github_create_issue", "call_legacy"],
["tool.execute.after", "github_create_issue", "call_legacy"],
])
expect(events[0]!.output).toEqual({ args: { title: "x" } })
// The after hook receives the raw MCP result, before model-facing shaping.
expect(events[1]!.output).toMatchObject({ content: [{ type: "text", text: "ok" }] })
}),
)
})
@@ -1,5 +1,5 @@
import { beforeAll, describe, expect, test } from "bun:test" import { beforeAll, describe, expect, test } from "bun:test"
import { CodeModeTool, describeCatalog } from "@/tool/code-mode" import { CodeModeTool, catalogInstructions } from "@/tool/code-mode"
import { McpCatalog } from "@/mcp/catalog" import { McpCatalog } from "@/mcp/catalog"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
@@ -17,9 +17,12 @@ import {
ListToolsRequestSchema, ListToolsRequestSchema,
type Tool as MCPToolDef, type Tool as MCPToolDef,
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Layer } from "effect" import type { Tool as AITool } from "ai"
import { Effect, Layer } from "effect"
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" // A 1x1 transparent PNG, base64-encoded, used to exercise image attachments.
const PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
const SERVER = "fixtures" const SERVER = "fixtures"
@@ -34,7 +37,16 @@ const ctx: Tool.Context = {
ask: () => Effect.void, ask: () => Effect.void,
} }
// Avoid the SDK Client here; other MCP tests mock it process-globally. /**
* A minimal JSON-RPC MCP client speaking the real protocol over the in-memory transport.
* Deliberately NOT the SDK `Client`: `test/mcp/lifecycle.test.ts` (and friends) replace
* `@modelcontextprotocol/sdk/client/index.js` via bun's `mock.module`, which is
* process-global and irreversible in a full `bun test` run (CI) any later import of the
* SDK Client gets the mock, whose `listTools` returns a canned `test_tool`. Speaking raw
* JSON-RPC keeps this suite's "real server, real transport, real protocol" property while
* being immune to that contamination. Only the surface `convertTool`/this file use is
* implemented: connect handshake, tools/list, tools/call.
*/
class RawJsonRpcClient { class RawJsonRpcClient {
private nextId = 1 private nextId = 1
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>() private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>()
@@ -44,7 +56,7 @@ class RawJsonRpcClient {
async connect() { async connect() {
this.transport.onmessage = (message) => { this.transport.onmessage = (message) => {
const msg = message as { id?: number; result?: unknown; error?: { message: string } } const msg = message as { id?: number; result?: unknown; error?: { message: string } }
if (msg.id === undefined) return if (msg.id === undefined) return // notifications/requests from the server are not needed here
const entry = this.pending.get(msg.id) const entry = this.pending.get(msg.id)
if (!entry) return if (!entry) return
this.pending.delete(msg.id) this.pending.delete(msg.id)
@@ -71,11 +83,16 @@ class RawJsonRpcClient {
return this.request("tools/list", {}) return this.request("tools/list", {})
} }
/** The `convertTool` surface: schema/options (timeouts, progress) are SDK-client
* concerns and are ignored here the server never sees them. */
callTool(params: { name: string; arguments?: Record<string, unknown> }, _schema?: unknown, _options?: unknown) { callTool(params: { name: string; arguments?: Record<string, unknown> }, _schema?: unknown, _options?: unknown) {
return this.request("tools/call", params) return this.request("tools/call", params)
} }
} }
// A real MCP server, exposed over an in-memory transport, with a representative mix
// of tools: plain text, structured data (with an outputSchema), an image, and a
// failing tool. Tools are defined with raw JSON Schema so outputSchema is exact.
const TOOL_DEFS: MCPToolDef[] = [ const TOOL_DEFS: MCPToolDef[] = [
{ {
name: "get_text", name: "get_text",
@@ -133,15 +150,21 @@ async function buildTool() {
await client.connect() await client.connect()
const listed = (await client.listTools()).tools as MCPToolDef[] const listed = (await client.listTools()).tools as MCPToolDef[]
const mcpTools: Record<string, MCP.McpTool> = {} const mcpTools: Record<string, AITool> = {}
const mcpDefs: Record<string, MCPToolDef> = {}
for (const def of listed) { for (const def of listed) {
mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client } const key = McpCatalog.toolName(SERVER, def.name)
mcpDefs[key] = def
mcpTools[key] = McpCatalog.convertTool(def, client as unknown as Client)
} }
// Truncate echoes its input so assertions read the exact program output; Agent/Session
// supply the (empty) permission rulesets the execute path merges; MCP serves the tools
// this real in-memory server listed — the same snapshot shape the live service returns.
const layer = Layer.mergeAll( const layer = Layer.mergeAll(
Layer.mock(Plugin.Service, { Layer.mock(Plugin.Service, {
trigger: ((_name: unknown, _input: unknown, output: unknown) => trigger: (((_name: unknown, _input: unknown, output: unknown) =>
Effect.succeed(output)) as Plugin.Interface["trigger"], Effect.succeed(output)) as Plugin.Interface["trigger"]),
}), }),
Layer.mock(Truncate.Service, { Layer.mock(Truncate.Service, {
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
@@ -150,22 +173,18 @@ async function buildTool() {
Layer.mock(Session.Service, { get: () => Effect.succeed({ permission: [] } as any) }), Layer.mock(Session.Service, { get: () => Effect.succeed({ permission: [] } as any) }),
Layer.mock(MCP.Service, { Layer.mock(MCP.Service, {
tools: () => Effect.succeed(mcpTools), tools: () => Effect.succeed(mcpTools),
defs: () => Effect.succeed(mcpDefs),
clients: () => Effect.succeed({ [SERVER]: {} as any }), clients: () => Effect.succeed({ [SERVER]: {} as any }),
}), }),
) )
return { return {
tool: await Effect.runPromise(CodeModeTool.pipe(Effect.flatMap(Tool.init), Effect.provide(layer))), tool: await Effect.runPromise(CodeModeTool.pipe(Effect.flatMap(Tool.init), Effect.provide(layer))),
description: describeCatalog(mcpTools, [SERVER]), // The catalog section the registry appends to the base description (describeCodeMode).
description: catalogInstructions(mcpTools, mcpDefs, [SERVER]),
} }
} }
const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx)) const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx))
// Program failures die at the tool boundary; recover the defect for message assertions.
const runFailed = async (code: string) => {
const exit = await Effect.runPromise(tool.execute({ code }, ctx).pipe(Effect.exit))
if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail")
return Cause.squash(exit.cause) as Error
}
beforeAll(async () => { beforeAll(async () => {
const built = await buildTool() const built = await buildTool()
@@ -177,10 +196,14 @@ describe("code mode integration (real MCP server)", () => {
test("the appended catalog inlines full signatures with real MCP schemas", () => { test("the appended catalog inlines full signatures with real MCP schemas", () => {
expect(description).toContain("Available tools (COMPLETE list") expect(description).toContain("Available tools (COMPLETE list")
expect(description).toContain("- fixtures (4 tools)") expect(description).toContain("- fixtures (4 tools)")
expect(description).toContain("tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>") expect(description).toContain(
"tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>",
)
expect(description).toContain("tools.fixtures.get_text(input: { name: string }): Promise<unknown>") expect(description).toContain("tools.fixtures.get_text(input: { name: string }): Promise<unknown>")
expect(description).toContain("// Add two numbers and return the structured sum") expect(description).toContain("// Add two numbers and return the structured sum")
// Small catalog: everything is inline, so no discovery tool is advertised.
expect(description).not.toContain("$codemode") expect(description).not.toContain("$codemode")
// The workflow section is present with placeholder-only call forms.
expect(description).toContain("## Workflow") expect(description).toContain("## Workflow")
expect(description).toContain("`const res = await tools.<namespace>.<tool>(input)`") expect(description).toContain("`const res = await tools.<namespace>.<tool>(input)`")
expect(description).not.toContain("total_count") expect(description).not.toContain("total_count")
@@ -229,6 +252,7 @@ describe("code mode integration (real MCP server)", () => {
value: "[1 image attached to the result]", value: "[1 image attached to the result]",
}) })
expect(out.output).not.toContain(PNG) expect(out.output).not.toContain(PNG)
// The stripped image still arrives as a real attachment.
expect(out.attachments).toHaveLength(1) expect(out.attachments).toHaveLength(1)
}) })
@@ -254,8 +278,9 @@ describe("code mode integration (real MCP server)", () => {
}) })
test("an uncaught MCP error surfaces as a failed execution", async () => { test("an uncaught MCP error surfaces as a failed execution", async () => {
const error = await runFailed("await tools.fixtures.boom({}); return 'unreachable'") const out = await run("await tools.fixtures.boom({}); return 'unreachable'")
expect(error.message).toContain("kaboom") expect(out.metadata.error).toBe(true)
expect(out.output).toContain("kaboom")
}) })
test("console output is captured and appended as a Logs section after the result", async () => { test("console output is captured and appended as a Logs section after the result", async () => {
@@ -270,13 +295,14 @@ describe("code mode integration (real MCP server)", () => {
}) })
test("console output is preserved on the error path", async () => { test("console output is preserved on the error path", async () => {
const error = await runFailed(` const out = await run(`
console.log("before the throw") console.log("before the throw")
await tools.fixtures.boom({}) await tools.fixtures.boom({})
return "unreachable" return "unreachable"
`) `)
expect(error.message).toContain("kaboom") expect(out.metadata.error).toBe(true)
expect(error.message).toContain("Logs:\nbefore the throw") expect(out.output).toContain("kaboom")
expect(out.output).toContain("Logs:\nbefore the throw")
}) })
test("a program that logs nothing gets no Logs section", async () => { test("a program that logs nothing gets no Logs section", async () => {
+293 -146
View File
@@ -1,5 +1,15 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode" import {
CODE_MODE_TOOL,
CodeModeTool,
Parameters,
catalogInstructions,
formatValue,
groupByServer,
toSandboxResult,
withLogs,
type Attachment,
} from "@/tool/code-mode"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
@@ -9,8 +19,10 @@ import { Plugin } from "@/plugin"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { Tool } from "@/tool/tool" import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate" import * as Truncate from "@/tool/truncate"
import { McpCatalog } from "@/mcp/catalog"
import { MessageID, SessionID } from "@/session/schema" import { MessageID, SessionID } from "@/session/schema"
import { Cause, Effect, Exit, Layer, Schema } from "effect" import type { Tool as AITool } from "ai"
import { Effect, Layer, Schema } from "effect"
const ctx: Tool.Context = { const ctx: Tool.Context = {
sessionID: SessionID.make("ses_code-mode"), sessionID: SessionID.make("ses_code-mode"),
@@ -23,22 +35,28 @@ const ctx: Tool.Context = {
ask: () => Effect.void, ask: () => Effect.void,
} }
// Build a real MCP-derived AI SDK tool over a fake transport, so the adapter exercises
// the same `convertTool` execution path that `mcp.tools()` produces at runtime.
function mcpTool( function mcpTool(
name: string, name: string,
handler: (args: Record<string, unknown>) => unknown, handler: (args: Record<string, unknown>) => unknown,
inputSchema: Record<string, unknown> = { type: "object", properties: {} }, inputSchema: Record<string, unknown> = { type: "object", properties: {} },
outputSchema?: Record<string, unknown>, ): AITool {
): MCP.McpTool { const client = {
return { callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
def: { name, description: name, inputSchema, ...(outputSchema ? { outputSchema } : {}) } as MCPToolDef,
client: {
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
} as unknown as MCP.McpTool["client"],
} }
return McpCatalog.convertTool({ name, description: name, inputSchema } as any, client as any)
} }
// Truncate echoes its input so assertions read the exact program output. Agent.get is
// consulted by the shared wrapper during truncation AND at execute time for the
// permission ruleset that filters the dispatchable tool tree; Session.get supplies the
// (empty) session-level ruleset half of that merge. Plugin.trigger defaults to the
// pass-through the real service uses when no plugin implements a hook; tests observing
// or failing hooks override it.
function harness(input: { function harness(input: {
mcpTools: Record<string, MCP.McpTool> mcpTools: Record<string, AITool>
defs?: Record<string, MCPToolDef>
servers: string[] servers: string[]
permission?: PermissionV1.Rule[] permission?: PermissionV1.Rule[]
trigger?: Plugin.Interface["trigger"] trigger?: Plugin.Interface["trigger"]
@@ -58,17 +76,21 @@ function harness(input: {
}), }),
Layer.mock(MCP.Service, { Layer.mock(MCP.Service, {
tools: () => Effect.succeed(input.mcpTools), tools: () => Effect.succeed(input.mcpTools),
defs: () => Effect.succeed(input.defs ?? {}),
clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))), clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))),
}), }),
) )
} }
function serverNames(mcpTools: Record<string, MCP.McpTool>, servers?: string[]) { // Derive sanitized server namespaces from the catalog keys, mirroring how the registry
// passes `Object.keys(mcp.clients()).map(sanitize)`.
function serverNames(mcpTools: Record<string, AITool>, servers?: string[]) {
return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
} }
function build( function build(
mcpTools: Record<string, MCP.McpTool>, mcpTools: Record<string, AITool>,
defs: Record<string, MCPToolDef> = {},
servers?: string[], servers?: string[],
permission?: PermissionV1.Rule[], permission?: PermissionV1.Rule[],
trigger?: Plugin.Interface["trigger"], trigger?: Plugin.Interface["trigger"],
@@ -77,20 +99,20 @@ function build(
return Effect.runPromise( return Effect.runPromise(
CodeModeTool.pipe( CodeModeTool.pipe(
Effect.flatMap(Tool.init), Effect.flatMap(Tool.init),
Effect.provide(harness({ mcpTools, servers: names, permission, trigger })), Effect.provide(harness({ mcpTools, defs, servers: names, permission, trigger })),
), ),
) )
} }
function describeFor(mcpTools: Record<string, MCP.McpTool>, servers?: string[], permission: PermissionV1.Rule[] = []) { // The agent-facing description, as the registry composes it (`describeCodeMode`):
return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers)) // permission-filtered tool set → grouped catalog → CodeMode instructions.
} function describeFor(
mcpTools: Record<string, AITool>,
// Program failures die at the tool boundary; recover the defect for message assertions. defs: Record<string, MCPToolDef> = {},
async function failure(effect: Effect.Effect<unknown>) { servers?: string[],
const exit = await Effect.runPromise(effect.pipe(Effect.exit)) permission: PermissionV1.Rule[] = [],
if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail") ) {
return Cause.squash(exit.cause) as Error return catalogInstructions(Permission.visibleTools(mcpTools, permission), defs, serverNames(mcpTools, servers))
} }
describe("code mode execute", () => { describe("code mode execute", () => {
@@ -101,30 +123,38 @@ describe("code mode execute", () => {
}) })
test("groups multi-underscore server names by longest matching prefix", () => { test("groups multi-underscore server names by longest matching prefix", () => {
const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"]) const groups = groupByServer({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
expect(description).toContain("- my_server (1 tool)") expect([...groups.keys()]).toEqual(["my_server"])
expect(description).toContain("tools.my_server.do_thing(") expect(groups.get("my_server")![0]).toMatchObject({
path: "my_server.do_thing",
local: "do_thing",
key: "my_server_do_thing",
})
}) })
test("groupByServer uses the whole key as the server name when it has no underscore", () => { test("groupByServer uses the whole key as the server name when it has no underscore", () => {
const description = describeFor({ standalone: mcpTool("standalone", () => "") }, []) const groups = groupByServer({ standalone: mcpTool("standalone", () => "") }, [])
expect(description).toContain("- standalone (1 tool)") expect([...groups.keys()]).toEqual(["standalone"])
expect(description).toContain("tools.standalone.standalone(") expect(groups.get("standalone")![0]).toMatchObject({
path: "standalone.standalone",
server: "standalone",
local: "standalone",
key: "standalone",
})
}) })
test("describeCatalog carries the raw MCP schemas for rendering", () => { test("groupByServer carries the raw MCP schemas for rendering", () => {
const description = describeFor( const defs: Record<string, MCPToolDef> = {
{ weather_current: {
weather_current: mcpTool( name: "current",
"current", inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
() => "", outputSchema: { type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] }, } as any,
{ type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] }, }
), const groups = groupByServer({ weather_current: mcpTool("current", () => "") }, ["weather"], defs)
}, const entry = groups.get("weather")![0]!
["weather"], expect(entry.inputSchema).toEqual(defs.weather_current!.inputSchema as any)
) expect(entry.outputSchema).toEqual(defs.weather_current!.outputSchema as any)
expect(description).toContain("tools.weather.current(input: { city: string }): Promise<{ tempC: number }>")
}) })
test("the static base description carries no catalog; the registry appends it", async () => { test("the static base description carries no catalog; the registry appends it", async () => {
@@ -154,76 +184,90 @@ describe("code mode execute", () => {
) )
expect(description).toContain("tools.github.list_issues(") expect(description).toContain("tools.github.list_issues(")
expect(description).toContain("tools.linear.search(") expect(description).toContain("tools.linear.search(")
// A schema with no properties renders as an empty object, not `{ }`.
expect(description).toContain("tools.linear.search(input: {}): Promise<unknown>") expect(description).toContain("tools.linear.search(input: {}): Promise<unknown>")
// Fully inlined catalog: no discovery round-trip is needed or advertised.
expect(description).not.toContain("$codemode") expect(description).not.toContain("$codemode")
expect(description).not.toContain("Browse one namespace") expect(description).not.toContain("Browse one namespace")
// The workflow/rules sections use placeholder call forms only — the example machinery
// never cherry-picks a catalog tool or fabricates result fields.
expect(description).toContain("## Workflow") expect(description).toContain("## Workflow")
expect(description).toContain("1. Pick a tool from the list under `## Available tools`") expect(description).toContain("1. Pick a tool from the list under `## Available tools`")
expect(description).toContain( expect(description).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string')
'`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
)
expect(description).toContain("Return only the fields you need") expect(description).toContain("Return only the fields you need")
expect(description).not.toContain("total_count") expect(description).not.toContain("total_count")
}) })
test("signatures render the declared outputSchema as the return type", () => { test("signatures render the declared outputSchema as the return type", () => {
const description = describeFor({ const defs: Record<string, MCPToolDef> = {
weather_current: mcpTool( weather_current: {
"current", name: "current",
() => "", inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] }, outputSchema: {
{
type: "object", type: "object",
properties: { tempC: { type: "number" }, summary: { type: "string" } }, properties: { tempC: { type: "number" }, summary: { type: "string" } },
required: ["tempC"], required: ["tempC"],
}, },
), } as any,
}) }
const description = describeFor({ weather_current: mcpTool("current", () => "") }, defs)
expect(description).toContain( expect(description).toContain(
"tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>", "tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>",
) )
}) })
test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => { test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => {
const tools: Record<string, MCP.McpTool> = {} const tools: Record<string, AITool> = {}
const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3) const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3)
for (let i = 0; i < 150; i++) { for (let i = 0; i < 150; i++) {
tools[`alpha_op_${i}`] = { const client = { callTool: async () => ({ content: [] }) }
def: { tools[`alpha_op_${i}`] = McpCatalog.convertTool(
{
name: `op_${i}`, name: `op_${i}`,
description: `${filler}${i}`, description: `${filler}${i}`,
inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } }, inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
} as MCPToolDef, } as any,
client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"], client as any,
} )
} }
tools["zeta_only_tool"] = mcpTool("only_tool", () => "", { tools["zeta_only_tool"] = mcpTool("only_tool", () => "", {
type: "object", type: "object",
properties: { topic: { type: "string", description: "Subject to look up" } }, properties: { topic: { type: "string", description: "Subject to look up" } },
required: ["topic"], required: ["topic"],
}) })
const description = describeFor(tools, ["alpha", "zeta"]) const description = describeFor(tools, {}, ["alpha", "zeta"])
// Every namespace is listed with counts; signatures inline round-robin across
// namespaces (cheapest-first within each) until the budget runs out, and the
// description states exactly how comprehensive the list is. Round-robin fairness:
// the small zeta namespace is fully shown even though alpha alone could exhaust
// the whole budget.
expect(description).toContain("Available tools (PARTIAL - ") expect(description).toContain("Available tools (PARTIAL - ")
expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/) expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/)
expect(description).toContain("- zeta (1 tool)\n") expect(description).toContain("- zeta (1 tool)\n")
expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>") expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>")
expect(description).toContain("tools.$codemode.search(") expect(description).toContain("tools.$codemode.search(")
// PARTIAL catalogs put search first in the workflow and advertise namespace browsing.
expect(description).toContain("1. Find a tool (skip when it is already listed below)") expect(description).toContain("1. Find a tool (skip when it is already listed below)")
expect(description).toContain( expect(description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.')
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
)
expect(description).not.toContain("total_count") expect(description).not.toContain("total_count")
// All op lines cost the same estimated tokens (chars/4 rounds away the 1- vs 3-digit
// name difference), so the path tiebreak decides: the lexicographically-first ops made
// the cut and the lexicographic tail (op_99 is maximal) did not.
expect(description).toContain("tools.alpha.op_0(") expect(description).toContain("tools.alpha.op_0(")
expect(description).not.toContain("tools.alpha.op_99(") expect(description).not.toContain("tools.alpha.op_99(")
const tool = await build(tools, ["alpha", "zeta"]) // The runtime search tool works in-program and returns complete signatures.
const tool = await build(tools, {}, ["alpha", "zeta"])
const out = await Effect.runPromise( const out = await Effect.runPromise(
tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx), tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx),
) )
const result = JSON.parse(out.output) const result = JSON.parse(out.output)
// Search-result paths carry the `tools.` prefix so each is directly usable as a call site.
expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool") expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool")
expect(result.items[0].signature).toContain("tools.") expect(result.items[0].signature).toContain("tools.")
// Search results render the pretty multiline signature: MCP input-property
// descriptions ride along as JSDoc field comments. The inline catalog stays compact.
const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature
expect(signature).toContain("tools.zeta.only_tool(input: {\n") expect(signature).toContain("tools.zeta.only_tool(input: {\n")
expect(signature).toContain(" /** Subject to look up */\n topic: string") expect(signature).toContain(" /** Subject to look up */\n topic: string")
@@ -246,10 +290,7 @@ describe("code mode execute", () => {
linear_search: mcpTool("search", () => ""), linear_search: mcpTool("search", () => ""),
}) })
const output = await Effect.runPromise( const output = await Effect.runPromise(
tool.execute( tool.execute({ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" }, ctx),
{ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" },
ctx,
),
) )
expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 }) expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 })
}) })
@@ -320,16 +361,18 @@ describe("code mode execute", () => {
expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true) expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true)
}) })
test("a program failure fails the tool with a readable error", async () => { test("returns a readable error when the program throws", async () => {
const tool = await build({}) const tool = await build({})
const error = await failure(tool.execute({ code: "throw new Error('boom')" }, ctx)) const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx))
expect(error.message).toBe("Uncaught: boom") expect(output.output).toBe("Uncaught: boom")
expect(output.metadata.error).toBe(true)
}) })
test("reports an unknown tool as a failed execution", async () => { test("reports an unknown tool as a failed execution", async () => {
const tool = await build({ known_tool: mcpTool("tool", () => "ok") }) const tool = await build({ known_tool: mcpTool("tool", () => "ok") })
const error = await failure(tool.execute({ code: "return await tools.known.missing({})" }, ctx)) const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
expect(error.message).toContain("Unknown tool 'known.missing'") expect(output.metadata.error).toBe(true)
expect(output.output).toContain("Unknown tool 'known.missing'")
}) })
test("propagates an MCP tool error into the program as a catchable failure", async () => { test("propagates an MCP tool error into the program as a catchable failure", async () => {
@@ -371,6 +414,7 @@ describe("code mode execute", () => {
expect(output.output).toBe("denied: permission denied by user") expect(output.output).toBe("denied: permission denied by user")
expect(output.metadata.error).toBeUndefined() expect(output.metadata.error).toBeUndefined()
// The MCP tool itself never ran.
expect(called).toEqual([]) expect(called).toEqual([])
expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }]) expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }])
}) })
@@ -387,6 +431,7 @@ describe("code mode execute", () => {
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })), a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })), b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
}, },
{},
undefined, undefined,
undefined, undefined,
trigger, trigger,
@@ -397,6 +442,7 @@ describe("code mode execute", () => {
) )
expect(out.output).toBe("done") expect(out.output).toBe("done")
// callID is synthetic and per-execution: `${parentCallID}/${n}`, n starting at 1.
expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([ expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
["tool.execute.before", "a_tool", "call_code_mode/1"], ["tool.execute.before", "a_tool", "call_code_mode/1"],
["tool.execute.after", "a_tool", "call_code_mode/1"], ["tool.execute.after", "a_tool", "call_code_mode/1"],
@@ -407,6 +453,7 @@ describe("code mode execute", () => {
expect(before!.input.sessionID).toBe(ctx.sessionID) expect(before!.input.sessionID).toBe(ctx.sessionID)
expect(before!.output).toEqual({ args: { x: 1 } }) expect(before!.output).toEqual({ args: { x: 1 } })
expect(after!.input.args).toEqual({ x: 1 }) expect(after!.input.args).toEqual({ x: 1 })
// The after hook sees the raw MCP result — the same payload the legacy path passes.
expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] }) expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] })
}) })
@@ -422,6 +469,7 @@ describe("code mode execute", () => {
} }
const tool = await build( const tool = await build(
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) }, { a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
{},
undefined, undefined,
undefined, undefined,
trigger, trigger,
@@ -441,8 +489,10 @@ describe("code mode execute", () => {
), ),
) )
// The program handled the hook failure; the rest ran and the outer result is ok.
expect(out.metadata.error).toBeUndefined() expect(out.metadata.error).toBeUndefined()
expect(out.output).toBe("hook exploded / ok") expect(out.output).toBe("hook exploded / ok")
// The before hook gates dispatch: the failed child's tool never executed.
expect(called).toEqual(["b"]) expect(called).toEqual(["b"])
}) })
@@ -458,6 +508,7 @@ describe("code mode execute", () => {
tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx), tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx),
) )
// The UI sees the call appear as running, then resolve to completed.
expect(snapshots).toContainEqual({ expect(snapshots).toContainEqual({
toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }], toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }],
}) })
@@ -495,6 +546,7 @@ describe("code mode execute", () => {
}) })
const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx)) const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx))
// The program received the structured content; the media rode along host-side.
expect(JSON.parse(out.output)).toEqual({ name: "shot.png" }) expect(JSON.parse(out.output)).toEqual({ name: "shot.png" })
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }]) expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
expect(out.output).not.toContain("PNGDATA") expect(out.output).not.toContain("PNGDATA")
@@ -509,83 +561,27 @@ describe("code mode execute", () => {
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }]) expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
}) })
test("media-only markers distinguish all-image from mixed attachments", async () => {
const tool = await build({
media_images: mcpTool("images", () => ({
content: [
{ type: "image", data: "PNG1", mimeType: "image/png" },
{ type: "image", data: "PNG2", mimeType: "image/png" },
],
})),
media_mixed: mcpTool("mixed", () => ({
content: [
{ type: "image", data: "PNG3", mimeType: "image/png" },
{ type: "resource", resource: { uri: "file:///tmp/report.pdf", mimeType: "application/pdf", blob: "PDF1" } },
],
})),
})
const out = await Effect.runPromise(
tool.execute(
{
code: `
const images = await tools.media.images({})
const mixed = await tools.media.mixed({})
return { images, mixed }
`,
},
ctx,
),
)
expect(JSON.parse(out.output)).toEqual({
images: "[2 images attached to the result]",
mixed: "[2 files attached to the result]",
})
expect(out.output).not.toContain("PNG")
expect(out.attachments).toEqual([
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG1" },
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG2" },
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG3" },
{ type: "file", mime: "application/pdf", url: "data:application/pdf;base64,PDF1", filename: "report.pdf" },
])
})
test("resource links flow to the program as text, never as attachments", async () => {
const tool = await build({
docs_find: mcpTool("find", () => ({
content: [
{
type: "resource_link",
uri: "https://example.com/guide.pdf",
name: "guide.pdf",
mimeType: "application/pdf",
},
{ type: "resource_link", uri: "file:///tmp/notes.md", name: "notes.md" },
],
})),
})
const out = await Effect.runPromise(tool.execute({ code: "return await tools.docs.find({})" }, ctx))
expect(out.output).toBe("guide.pdf: https://example.com/guide.pdf\nnotes.md: file:///tmp/notes.md")
expect(out.attachments).toBeUndefined()
})
test("attachments still flow when the program returns something else entirely", async () => { test("attachments still flow when the program returns something else entirely", async () => {
const tool = await build({ const tool = await build({
shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })), shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
}) })
const out = await Effect.runPromise(tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx)) const out = await Effect.runPromise(
tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx),
)
expect(out.output).toBe("captured") expect(out.output).toBe("captured")
expect(out.attachments).toHaveLength(1) expect(out.attachments).toHaveLength(1)
}) })
test("isolates the sandbox from host globals", async () => { test("isolates the sandbox from host globals", async () => {
const tool = await build({}) const tool = await build({})
const error = await failure(tool.execute({ code: "return process.env" }, ctx)) const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx))
expect(error.message).toContain("process") expect(output.metadata.error).toBe(true)
}) })
test("cancelling via ctx.abort interrupts the running program", async () => { test("cancelling via ctx.abort interrupts the running program", async () => {
// The child call triggers the abort itself, deterministically, while the program
// swallows the call's failure and heads into an infinite loop. If abort did not
// interrupt the execution fiber, this test would hang on the busy loop.
const controller = new AbortController() const controller = new AbortController()
const tool = await build({ const tool = await build({
host_trigger: mcpTool("trigger", () => { host_trigger: mcpTool("trigger", () => {
@@ -617,6 +613,8 @@ describe("code mode execute", () => {
}) })
test("leaves oversized results to OpenCode's native tool-output truncation", async () => { test("leaves oversized results to OpenCode's native tool-output truncation", async () => {
// No CodeMode output limit is set, so the full result reaches the shared Tool.define
// wrapper intact (the harness Truncate fake passes it through un-truncated).
const tool = await build({}) const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx)) const output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx))
expect(output.metadata.error).toBeUndefined() expect(output.metadata.error).toBeUndefined()
@@ -632,9 +630,12 @@ describe("code mode execute", () => {
) )
expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful") expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful")
const error = await failure(tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx)) const err = await Effect.runPromise(
expect(error.message).toContain("Uncaught: boom") tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx),
expect(error.message).toContain("Logs:\nbefore the throw") )
expect(err.metadata.error).toBe(true)
expect(err.output).toContain("Uncaught: boom")
expect(err.output).toContain("Logs:\nbefore the throw")
}) })
}) })
@@ -648,7 +649,7 @@ describe("code mode permission visibility", () => {
github_create_issue: mcpTool("create_issue", ok), github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok), github_list_issues: mcpTool("list_issues", ok),
} }
const description = describeFor(mcpTools, ["github"], [deny("github_create_issue")]) const description = describeFor(mcpTools, {}, ["github"], [deny("github_create_issue")])
expect(description).toContain("tools.github.list_issues(") expect(description).toContain("tools.github.list_issues(")
expect(description).not.toContain("create_issue") expect(description).not.toContain("create_issue")
expect(description).toContain("- github (1 tool)") expect(description).toContain("- github (1 tool)")
@@ -659,7 +660,7 @@ describe("code mode permission visibility", () => {
github_create_issue: mcpTool("create_issue", ok), github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok), github_list_issues: mcpTool("list_issues", ok),
} }
const description = describeFor(mcpTools, ["github"], [askRule("github_create_issue")]) const description = describeFor(mcpTools, {}, ["github"], [askRule("github_create_issue")])
expect(description).toContain("tools.github.create_issue(") expect(description).toContain("tools.github.create_issue(")
expect(description).toContain("tools.github.list_issues(") expect(description).toContain("tools.github.list_issues(")
expect(description).toContain("- github (2 tools)") expect(description).toContain("- github (2 tools)")
@@ -675,16 +676,23 @@ describe("code mode permission visibility", () => {
}), }),
github_list_issues: mcpTool("list_issues", ok), github_list_issues: mcpTool("list_issues", ok),
}, },
{},
["github"], ["github"],
[deny("github_create_issue")], [deny("github_create_issue")],
) )
const denied = await failure(tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx)) const denied = await Effect.runPromise(
expect(denied.message).toContain("Unknown tool 'github.create_issue'") tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx),
expect(denied.message).not.toContain("permission") )
expect(denied.metadata.error).toBe(true)
expect(denied.output).toContain("Unknown tool 'github.create_issue'")
expect(denied.output).not.toContain("permission")
expect(called).toEqual([]) expect(called).toEqual([])
const allowed = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, ctx)) // The rest of the namespace still works.
const allowed = await Effect.runPromise(
tool.execute({ code: "return await tools.github.list_issues({})" }, ctx),
)
expect(allowed.metadata.error).toBeUndefined() expect(allowed.metadata.error).toBeUndefined()
expect(allowed.output).toBe("ok") expect(allowed.output).toBe("ok")
}) })
@@ -694,10 +702,13 @@ describe("code mode permission visibility", () => {
const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) } const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) }
const tool = await build( const tool = await build(
{ github_list_issues: mcpTool("list_issues", ok) }, { github_list_issues: mcpTool("list_issues", ok) },
{},
["github"], ["github"],
[askRule("github_list_issues")], [askRule("github_list_issues")],
) )
const out = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx)) const out = await Effect.runPromise(
tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx),
)
expect(out.output).toBe("ok") expect(out.output).toBe("ok")
expect(asked).toEqual(["github_list_issues"]) expect(asked).toEqual(["github_list_issues"])
}) })
@@ -707,8 +718,144 @@ describe("code mode permission visibility", () => {
const visible = Permission.visibleTools(tools, [ const visible = Permission.visibleTools(tools, [
deny("a_tool"), deny("a_tool"),
askRule("b_tool"), askRule("b_tool"),
// A scoped (non-"*") deny does not hide the tool from the catalog.
{ permission: "c_tool", pattern: "something", action: "deny" }, { permission: "c_tool", pattern: "something", action: "deny" },
]) ])
expect(Object.keys(visible)).toEqual(["b_tool", "c_tool"]) expect(Object.keys(visible)).toEqual(["b_tool", "c_tool"])
}) })
}) })
describe("toSandboxResult", () => {
const collector = () => {
const attachments: Attachment[] = []
return { attachments, collect: (a: Attachment) => void attachments.push(a) }
}
test("prefers structuredContent over text", () => {
const { collect } = collector()
expect(toSandboxResult({ structuredContent: { x: 1 }, content: [{ type: "text", text: "hi" }] }, collect)).toEqual(
{ x: 1 },
)
})
test("joins text content when no structured content is present", () => {
const { collect } = collector()
expect(
toSandboxResult(
{ content: [{ type: "text", text: "one" }, { type: "text", text: "two" }] },
collect,
),
).toBe("one\ntwo")
})
test("passes non-MCP values through untouched", () => {
const { collect } = collector()
expect(toSandboxResult("raw", collect)).toBe("raw")
expect(toSandboxResult(42, collect)).toBe(42)
expect(toSandboxResult(null, collect)).toBeNull()
expect(toSandboxResult({ some: "object" }, collect)).toEqual({ some: "object" })
})
test("strips media into the accumulator; text stays the sandbox value", () => {
const { attachments, collect } = collector()
const value = toSandboxResult(
{
content: [
{ type: "text", text: "see image" },
{ type: "image", data: "AAAA", mimeType: "image/png" },
],
},
collect,
)
expect(value).toBe("see image")
expect(attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }])
})
test("a media-only result yields a marker; counts and nouns follow the content", () => {
const one = collector()
expect(toSandboxResult({ content: [{ type: "image", data: "A", mimeType: "image/png" }] }, one.collect)).toBe(
"[1 image attached to the result]",
)
const two = collector()
expect(
toSandboxResult(
{
content: [
{ type: "image", data: "A", mimeType: "image/png" },
{ type: "image", data: "B", mimeType: "image/jpeg" },
],
},
two.collect,
),
).toBe("[2 images attached to the result]")
expect(two.attachments).toHaveLength(2)
const mixed = collector()
expect(
toSandboxResult(
{
content: [
{ type: "image", data: "A", mimeType: "image/png" },
{ type: "audio", data: "B", mimeType: "audio/wav" },
],
},
mixed.collect,
),
).toBe("[2 files attached to the result]")
})
test("extracts embedded resources: text inline, blobs as attachments with filenames", () => {
const { attachments, collect } = collector()
const value = toSandboxResult(
{
content: [
{ type: "resource", resource: { uri: "file:///tmp/notes.txt", mimeType: "text/plain", text: "note text" } },
{ type: "resource", resource: { uri: "file:///tmp/doc.pdf", mimeType: "application/pdf", blob: "PDF" } },
],
},
collect,
)
expect(value).toBe("note text")
expect(attachments).toEqual([
{ type: "file", mime: "application/pdf", url: "data:application/pdf;base64,PDF", filename: "doc.pdf" },
])
})
test("collects resource_link blocks as external-URL attachments", () => {
const { attachments, collect } = collector()
const value = toSandboxResult(
{ content: [{ type: "resource_link", uri: "https://example.com/report.csv", mimeType: "text/csv" }] },
collect,
)
expect(value).toBe("[1 file attached to the result]")
expect(attachments).toEqual([
{ type: "file", mime: "text/csv", url: "https://example.com/report.csv", filename: "report.csv" },
])
})
test("an MCP-shaped result with nothing extractable becomes null", () => {
const { collect } = collector()
expect(toSandboxResult({ content: [] }, collect)).toBeNull()
expect(toSandboxResult({ content: [{ type: "mystery" }] }, collect)).toBeNull()
})
})
describe("formatting helpers", () => {
test("formatValue", () => {
expect(formatValue("text")).toBe("text")
expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2))
expect(formatValue(null)).toBe("null")
expect(formatValue(undefined)).toBe("undefined")
})
test("withLogs", () => {
// No logs: output is returned untouched.
expect(withLogs("result", [])).toBe("result")
expect(withLogs("result")).toBe("result")
// Logs are appended as a trailing section, one line each.
expect(withLogs("result", ["a", "[warn] b"])).toBe("result\n\nLogs:\na\n[warn] b")
// Empty output still gets the section (no leading blank lines).
expect(withLogs("", ["[error] boom"])).toBe("Logs:\n[error] boom")
})
})
@@ -17,6 +17,8 @@ import { InstanceState } from "@/effect/instance-state"
import { ToolJsonSchema } from "@/tool/json-schema" import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema" import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@@ -57,6 +59,45 @@ const replacements = [
const it = testEffect(LayerNode.compile(root, replacements)) const it = testEffect(LayerNode.compile(root, replacements))
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]])) const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
// Fake MCP.Service serving two tools on one server, built through the same
// `convertTool` path the real service uses so the code-mode catalog renders
// genuine signatures.
function fakeMcpLayer(tools: Record<string, { description: string }>) {
const client = { callTool: async () => ({ content: [] }) }
const converted = Object.fromEntries(
Object.entries(tools).map(([key, def]) => [
key,
McpCatalog.convertTool(
{
name: key,
description: def.description,
inputSchema: { type: "object", properties: {} },
} as any,
client as any,
),
]),
)
return Layer.mock(MCP.Service, {
tools: () => Effect.succeed(converted),
defs: () => Effect.succeed({}),
clients: () => Effect.succeed(Object.keys(tools).length > 0 ? ({ github: {} } as any) : {}),
})
}
const mcpToolsLayer = fakeMcpLayer({
github_create_issue: { description: "Create an issue" },
github_list_issues: { description: "List issues" },
})
const codeModeFlags = [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })] as const
const withCodeMode = testEffect(
LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, mcpToolsLayer]]),
)
const withCodeModeNoMcp = testEffect(
LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, fakeMcpLayer({})]]),
)
const withFlagOff = testEffect(LayerNode.compile(root, [...replacements, [MCP.node, mcpToolsLayer]]))
afterEach(async () => { afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
}) })
@@ -491,3 +532,82 @@ describe("tool.registry", () => {
}), }),
) )
}) })
describe("tool.registry code mode", () => {
const model = { providerID: ProviderV2.ID.opencode, modelID: ModelV2.ID.make("test") }
withCodeMode.instance("registers the code-mode execute tool when the flag is on and MCP tools exist", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() })
const execute = tools.find((tool) => tool.id === "execute")
if (!execute) throw new Error("code-mode execute tool was not registered")
// The registry appends the grouped catalog to the static base description.
expect(execute.description).toContain("confined runtime")
expect(execute.description).toContain("## Available tools")
expect(execute.description).toContain("tools.github.create_issue(")
expect(execute.description).toContain("tools.github.list_issues(")
}),
)
withCodeModeNoMcp.instance("does not register code mode when no MCP tools are connected", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() })
expect(tools.map((tool) => tool.id)).not.toContain("execute")
}),
)
withFlagOff.instance("does not register code mode when the flag is off, even with MCP tools", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() })
expect(tools.map((tool) => tool.id)).not.toContain("execute")
}),
)
withCodeMode.instance("hard-denied MCP tools are dropped from the catalog; ask-level ones stay", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const base = yield* agents.defaultInfo()
const agent = {
...base,
permission: [
...base.permission,
{ permission: "github_create_issue", pattern: "*" as const, action: "deny" as const },
{ permission: "github_list_issues", pattern: "*" as const, action: "ask" as const },
],
}
const tools = yield* registry.tools({ ...model, agent })
const execute = tools.find((tool) => tool.id === "execute")
if (!execute) throw new Error("code-mode execute tool was not registered")
expect(execute.description).not.toContain("create_issue")
expect(execute.description).toContain("tools.github.list_issues(")
expect(execute.description).toContain("- github (1 tool)")
}),
)
withCodeMode.instance("session-level hard-denied MCP tools are dropped from the code-mode catalog", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const tools = yield* registry.tools({
...model,
agent: yield* agents.defaultInfo(),
permission: [
{ permission: "github_create_issue", pattern: "*" as const, action: "deny" as const },
{ permission: "github_list_issues", pattern: "*" as const, action: "ask" as const },
],
})
const execute = tools.find((tool) => tool.id === "execute")
if (!execute) throw new Error("code-mode execute tool was not registered")
expect(execute.description).not.toContain("create_issue")
expect(execute.description).toContain("tools.github.list_issues(")
expect(execute.description).toContain("- github (1 tool)")
}),
)
})
-1
View File
@@ -34,7 +34,6 @@ export const AISDK = Schema.Struct({
export interface Native extends Schema.Schema.Type<typeof Native> {} export interface Native extends Schema.Schema.Type<typeof Native> {}
export const Native = Schema.Struct({ export const Native = Schema.Struct({
type: Schema.Literal("native"), type: Schema.Literal("native"),
package: Schema.String.pipe(optional),
url: Schema.String.pipe(optional), url: Schema.String.pipe(optional),
settings: Schema.Record(Schema.String, Schema.Unknown), settings: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "Provider.Native" }) }).annotate({ identifier: "Provider.Native" })
-2
View File
@@ -4779,7 +4779,6 @@ export type ModelApi =
| { | {
id: string id: string
type: "native" type: "native"
package?: string
url?: string url?: string
settings: { settings: {
[key: string]: unknown [key: string]: unknown
@@ -4854,7 +4853,6 @@ export type ProviderAisdk = {
export type ProviderNative = { export type ProviderNative = {
type: "native" type: "native"
package?: string
url?: string url?: string
settings: { settings: {
[key: string]: unknown [key: string]: unknown
@@ -230,7 +230,6 @@
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
text-transform: capitalize; text-transform: capitalize;
color: var(--task-agent-legacy-color, var(--text-strong));
} }
[data-slot="basic-tool-tool-subtitle"] { [data-slot="basic-tool-tool-subtitle"] {
@@ -248,57 +247,6 @@
} }
} }
body[data-new-layout] {
[data-component="task-tool-card"] {
padding: 8px 12px 8px 10px;
border-radius: 8px;
border: 0;
border-color: transparent;
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
box-shadow: inset 0 0 0 0.5px var(--task-agent-border, var(--v2-border-border-base));
[data-component="task-tool-spinner"],
[data-component="task-tool-title"] {
color: var(--task-agent-color, var(--text-strong));
}
[data-component="task-tool-title"] {
height: 20px;
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-style: normal;
font-weight: 530;
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
flex: none;
flex-grow: 0;
}
[data-slot="basic-tool-tool-subtitle"] {
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
color: light-dark(var(--v2-text-text-base), #fafafa);
font-variation-settings: "slnt" 0;
flex: none;
flex-grow: 0;
}
[data-component="session-progress-indicator-v2"] {
color: var(--task-agent-color, light-dark(var(--v2-text-text-base), #ffffff));
}
&:hover,
&:focus-visible {
border-color: transparent;
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
}
}
}
body:not([data-new-layout]) { body:not([data-new-layout]) {
[data-component="tool-trigger"] { [data-component="tool-trigger"] {
[data-slot="basic-tool-tool-spinner"] { [data-slot="basic-tool-tool-spinner"] {
@@ -62,7 +62,6 @@ import { animate } from "motion"
import { useLocation } from "@solidjs/router" import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file" import { attached, inline, kind } from "./message-file"
import { readPartText } from "./message-part-text" import { readPartText } from "./message-part-text"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
async function writeClipboard(text: string): Promise<boolean> { async function writeClipboard(text: string): Promise<boolean> {
const body = typeof document === "undefined" ? undefined : document.body const body = typeof document === "undefined" ? undefined : document.body
@@ -371,44 +370,6 @@ const agentTones: Record<string, string> = {
plan: "var(--icon-agent-plan-base)", plan: "var(--icon-agent-plan-base)",
} }
const v2AgentTones: Record<string, { color: string; border: string; background: string }> = {
build: {
color: "var(--v2-agent-build-solid)",
border: "var(--v2-agent-build-border)",
background: "var(--v2-agent-build-background)",
},
explore: {
color: "var(--v2-agent-explore-solid)",
border: "var(--v2-agent-explore-border)",
background: "var(--v2-agent-explore-background)",
},
plan: {
color: "var(--v2-agent-plan-solid)",
border: "var(--v2-agent-plan-border)",
background: "var(--v2-agent-plan-background)",
},
}
const agentThemeColors: Record<string, string> = {
primary: "var(--text-interactive-base)",
secondary: "var(--text-base)",
accent: "var(--icon-info-base)",
success: "var(--icon-success-base)",
warning: "var(--icon-warning-base)",
error: "var(--icon-critical-base)",
info: "var(--icon-info-base)",
}
const v2AgentThemeColors: Record<string, string> = {
primary: "var(--v2-text-text-accent)",
secondary: "var(--v2-text-text-muted)",
accent: "var(--v2-icon-icon-accent)",
success: "var(--v2-state-fg-success)",
warning: "var(--v2-state-fg-warning)",
error: "var(--v2-state-fg-danger)",
info: "var(--v2-state-fg-info)",
}
const agentPalette = [ const agentPalette = [
"var(--icon-agent-ask-base)", "var(--icon-agent-ask-base)",
"var(--icon-agent-build-base)", "var(--icon-agent-build-base)",
@@ -433,31 +394,16 @@ function tone(name: string) {
function taskAgent( function taskAgent(
raw: unknown, raw: unknown,
list?: readonly { name: string; color?: string }[], list?: readonly { name: string; color?: string }[],
): { name?: string; color?: string; v2Color?: string; border?: string; background?: string } { ): { name?: string; color?: string } {
if (typeof raw !== "string" || !raw) return {} if (typeof raw !== "string" || !raw) return {}
const key = raw.toLowerCase() const key = raw.toLowerCase()
const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key) const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
const v2Tone = item?.color ? undefined : v2AgentTones[key]
const color = agentColor(item?.color, agentThemeColors) ?? agentTones[key] ?? tone(key)
const v2Color = agentColor(item?.color, v2AgentThemeColors) ?? v2Tone?.color ?? color
return { return {
name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`, name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
color, color: item?.color ?? agentTones[key] ?? tone(key),
v2Color,
border: v2Tone?.border ?? `color-mix(in srgb, ${v2Color} 48%, transparent)`,
background: v2Tone?.background ?? `color-mix(in srgb, ${v2Color} 12%, transparent)`,
} }
} }
function agentColor(value: string | undefined, themeColors: Record<string, string>) {
if (!value) return
return themeColors[value] ?? value
}
function newLayout() {
return typeof document !== "undefined" && document.body.hasAttribute("data-new-layout")
}
function webSearchProviderLabel(provider: unknown) { function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search" if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search" if (provider === "exa") return "Exa Web Search"
@@ -1936,9 +1882,6 @@ ToolRegistry.register({
const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent)) const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
const tone = createMemo(() => agent().color) const tone = createMemo(() => agent().color)
const v2Tone = createMemo(() => agent().v2Color)
const border = createMemo(() => agent().border)
const background = createMemo(() => agent().background)
const subtitle = createMemo(() => { const subtitle = createMemo(() => {
const value = const value =
typeof props.input.description === "string" && props.input.description typeof props.input.description === "string" && props.input.description
@@ -1978,27 +1921,17 @@ ToolRegistry.register({
} }
const trigger = () => ( const trigger = () => (
<div <div data-component="task-tool-card">
data-component="task-tool-card"
style={{
"--task-agent-color": v2Tone(),
"--task-agent-legacy-color": tone(),
"--task-agent-border": border(),
"--task-agent-background": background(),
}}
>
<div data-slot="basic-tool-tool-info-structured"> <div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main"> <div data-slot="basic-tool-tool-info-main">
<Show when={running()}> <Show when={running()}>
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}> <span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
<Show when={newLayout()} fallback={<Spinner />}> <Spinner />
<SessionProgressIndicatorV2
style={{ color: v2Tone() ?? "light-dark(var(--v2-text-text-base), #ffffff)" }}
/>
</Show>
</span> </span>
</Show> </Show>
<span data-component="task-tool-title">{title()}</span> <span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}>
{title()}
</span>
<Show when={subtitle()}> <Show when={subtitle()}>
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span> <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Show> </Show>
+1 -1
View File
@@ -198,7 +198,7 @@ const en = {
"model.knowledge": "Knowledge", "model.knowledge": "Knowledge",
"model.release": "Release", "model.release": "Release",
"model.inputs": "Inputs", "model.inputs": "Inputs",
"model.overviewDescription": "Recent tokens, unique users, and market position.", "model.overviewDescription": "Recent OpenCode Go tokens, unique users, and market position.",
"model.noSummaryTitle": "No usage summary", "model.noSummaryTitle": "No usage summary",
"model.noSummaryDescription": "This model has no OpenCode Go usage rows yet.", "model.noSummaryDescription": "This model has no OpenCode Go usage rows yet.",
"model.tokens": "Tokens", "model.tokens": "Tokens",
+189 -240
View File
@@ -48,7 +48,6 @@ import {
const statsUnfurlPath = "banner.png" const statsUnfurlPath = "banner.png"
const geoMapWidth = 960 const geoMapWidth = 960
const geoMapHeight = 430 const geoMapHeight = 430
const shortMonths = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const
type IsoCountryCode = readonly [string, string, string] type IsoCountryCode = readonly [string, string, string]
type WorldCountryProperties = GeoJsonProperties & { name?: string } type WorldCountryProperties = GeoJsonProperties & { name?: string }
@@ -120,7 +119,8 @@ export default function StatsModel() {
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString() const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
const modelHeaderLinks = createMemo<readonly HeaderLink[]>(() => [ const modelHeaderLinks = createMemo<readonly HeaderLink[]>(() => [
{ href: "#overview", label: i18n.t("nav.overview") }, { href: "#overview", label: i18n.t("nav.overview") },
{ href: "#momentum", label: i18n.t("model.momentum") }, { href: "#usage", label: i18n.t("nav.usage") },
{ href: "#users", label: i18n.t("nav.users") },
{ href: "#efficiency", label: i18n.t("nav.efficiency") }, { href: "#efficiency", label: i18n.t("nav.efficiency") },
{ href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") }, { href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") },
{ href: "#peers", label: i18n.t("nav.peers") }, { href: "#peers", label: i18n.t("nav.peers") },
@@ -181,8 +181,9 @@ export default function StatsModel() {
catalogData={catalog() ?? null} catalogData={catalog() ?? null}
labName={labName()} labName={labName()}
/> />
<ModelOverview catalog={catalogEntry() ?? null} /> <ModelOverview data={stats() ?? null} />
<ModelMomentumSection data={stats() ?? null} /> <ModelUsageSection data={stats()?.usage ?? []} />
<ModelUsersSection data={stats()?.usage ?? []} />
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} /> <ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
<ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} /> <ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
<ModelPeersSection data={stats() ?? null} /> <ModelPeersSection data={stats() ?? null} />
@@ -422,59 +423,15 @@ function ChevronDownIcon() {
) )
} }
function ModelOverview(props: { catalog: ModelCatalogEntry | null }) { function ModelOverview(props: { data: StatsModelData | null }) {
const i18n = useI18n() const i18n = useI18n()
const language = useLanguage()
const specs = createMemo(() => [
{
label: i18n.t("model.context"),
value: formatCatalogLimit(props.catalog?.limit?.context, i18n.t("home.unknown")),
},
{
label: i18n.t("model.output"),
value: formatCatalogLimit(props.catalog?.limit?.output, i18n.t("home.unknown")),
},
{
label: i18n.t("model.knowledge"),
value: formatCatalogMonth(props.catalog?.knowledge, language.tag(language.locale()), i18n.t("home.unknown")),
},
{
label: i18n.t("model.release"),
value: formatCatalogMonth(props.catalog?.releaseDate, language.tag(language.locale()), i18n.t("home.unknown")),
},
{
label: i18n.t("model.inputs"),
value: formatCatalogModalities(
props.catalog?.modalities.input ?? [],
language.tag(language.locale()),
i18n.t("home.unknown"),
),
},
])
return ( return (
<section id="model-overview" data-section="model-specs" aria-label={i18n.t("nav.overview")}> <section id="model-overview" data-section="model-panel">
<For each={specs()}> <SectionTitle
{(spec) => ( href="#model-overview"
<div data-component="model-spec"> title={i18n.t("nav.overview")}
<span>{spec.label}</span> description={i18n.t("model.overviewDescription")}
<strong>{spec.value}</strong> />
</div>
)}
</For>
</section>
)
}
function ModelMomentumSection(props: { data: StatsModelData | null }) {
const i18n = useI18n()
const language = useLanguage()
return (
<section id="momentum" data-section="model-momentum">
<h2 data-slot="model-momentum-title">
<a href="#momentum">{i18n.t("model.momentum")}.</a>
<span>{i18n.t("model.overviewDescription")}</span>
</h2>
<div data-slot="model-momentum-pattern" aria-hidden="true" />
<Show <Show
when={props.data} when={props.data}
fallback={ fallback={
@@ -482,83 +439,189 @@ function ModelMomentumSection(props: { data: StatsModelData | null }) {
} }
> >
{(data) => ( {(data) => (
<> <div data-component="model-metric-grid">
<MomentumChart data={data()} locale={language.tag(language.locale())} /> <MetricCard
<div data-slot="model-momentum-metrics"> label={i18n.t("model.tokens")}
<MomentumMetric label={i18n.t("model.uniqueUsers")} value={formatUsers(data().totals.uniqueUsers)} /> value={formatTokens(data().totals.tokens)}
<MomentumMetric detail={i18n.t("model.lastTwoMonths")}
label={capitalizeLabel(i18n.t("model.completedSessions"))} />
value={formatInteger(data().totals.sessions)} <MetricCard
/> label={i18n.t("model.uniqueUsers")}
<MomentumMetric label={i18n.t("model.tokenShare")} value={formatPercent(data().tokenShare)} /> value={formatUsers(data().totals.uniqueUsers)}
<MomentumMetric detail={i18n.t("model.lastTwoMonths")}
label="Rank" />
value={formatRankLabel(data().rank)} <MetricCard
watermark={formatRankLabel(data().rank)} label={i18n.t("model.sessions")}
/> value={formatInteger(data().totals.sessions)}
</div> detail={i18n.t("model.completedSessions")}
</> />
<MetricCard
label={i18n.t("model.tokenShare")}
value={formatPercent(data().tokenShare)}
detail={i18n.t("model.totalModels", { count: data().totalModels })}
/>
<MetricCard
label={i18n.t("model.momentum")}
value={formatChange(data().tokenChange)}
detail={i18n.t("model.vsPreviousWindow")}
state={data().tokenChange < 0 ? "negative" : "positive"}
/>
</div>
)} )}
</Show> </Show>
</section> </section>
) )
} }
function MomentumChart(props: { data: StatsModelData; locale: string }) { function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
const chart = createMemo(() => momentumChart(props.data.usage, props.data.updatedAt)) const i18n = useI18n()
const changeState = createMemo(() => (props.data.tokenChange < 0 ? "negative" : "positive"))
return ( return (
<div data-component="model-momentum-chart" role="img" aria-label="Recent model token momentum"> <section id="usage" data-section="model-panel">
<div data-slot="model-momentum-summary"> <SectionTitle href="#usage" title={i18n.t("nav.usage")} description={i18n.t("model.usageDescription")} />
<div data-slot="model-momentum-total"> <Show
<strong>{formatTokens(props.data.totals.tokens)} tokens</strong> when={props.data.some((item) => item.tokens > 0)}
<span data-state={changeState()}>{formatChange(props.data.tokenChange)}</span> fallback={
</div> <ModelEmptyState title={i18n.t("model.noUsageTitle")} description={i18n.t("model.noUsageDescription")} />
<p> }
<span>{formatMomentumDate(chart().startDate, props.locale, props.data.updatedAt)}</span> >
<span aria-hidden="true"></span> <ModelColumnChart data={props.data} metric="tokens" ariaLabel={i18n.t("model.dailyTokenChart")} />
<span>{formatMomentumDate(chart().endDate, props.locale, props.data.updatedAt)}</span> </Show>
</p> </section>
)
}
function ModelUsersSection(props: { data: ModelUsagePoint[] }) {
const i18n = useI18n()
return (
<section id="users" data-section="model-panel">
<SectionTitle href="#users" title={i18n.t("model.uniqueUsers")} description={i18n.t("model.usersDescription")} />
<Show
when={props.data.some((item) => item.users > 0)}
fallback={
<ModelEmptyState title={i18n.t("model.noUsersTitle")} description={i18n.t("model.noUsersDescription")} />
}
>
<ModelColumnChart data={props.data} metric="users" ariaLabel={i18n.t("model.dailyUserChart")} />
</Show>
</section>
)
}
function ModelColumnChart(props: { data: ModelUsagePoint[]; metric: "tokens" | "users"; ariaLabel: string }) {
const i18n = useI18n()
const [activeIndex, setActiveIndex] = createSignal<number>()
const max = createMemo(() => Math.max(0, ...props.data.map((item) => modelUsageMetricValue(item, props.metric))) || 1)
const activePoint = createMemo(() => {
const index = activeIndex()
if (index === undefined) return undefined
return props.data[index]
})
return (
<div
data-component="model-usage-chart"
data-metric={props.metric}
data-dense-labels={isModelUsageDense(props.data.length) ? "true" : undefined}
role="img"
aria-label={props.ariaLabel}
style={{ "--model-usage-count": props.data.length } as JSX.CSSProperties}
onPointerLeave={(event) => {
if (event.pointerType === "touch") return
setActiveIndex(undefined)
}}
>
<div data-slot="model-usage-axis" aria-hidden="true">
<For each={props.data}>
{(point, index) => (
<div
data-active={activeIndex() === index() ? "true" : undefined}
data-label-hidden={isModelUsageLabelHidden(index(), props.data.length) ? "true" : undefined}
>
<span data-slot="model-usage-label">
<span data-slot="model-usage-total">{formatModelUsageValue(point, props.metric)}</span>
<span data-slot="model-usage-date">{point.date}</span>
</span>
</div>
)}
</For>
</div> </div>
<div data-slot="model-momentum-plot"> <div data-slot="model-usage-bars">
<svg viewBox="0 0 1200 370" preserveAspectRatio="none" aria-hidden="true"> <For each={props.data}>
<path data-slot="model-momentum-line-muted" d={chart().previousPath} /> {(point, index) => (
<path data-slot="model-momentum-line-active" d={chart().currentPath} /> <div
<For each={chart().markers}> data-slot="model-usage-column"
{(marker) => ( role="button"
<rect tabIndex={0}
data-slot="model-momentum-marker" aria-label={`${point.date} ${formatModelUsageValue(point, props.metric)} ${modelUsageLabel(props.metric, i18n)}`}
data-active={marker.active ? "true" : undefined} data-active={activeIndex() === index() ? "true" : undefined}
x={marker.x - 3} data-muted={activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined}
y={marker.y - 3} onPointerDown={(event) => {
width="6" if (event.pointerType !== "touch") return
height="6" setActiveIndex(index())
}}
onPointerEnter={() => setActiveIndex(index())}
onPointerMove={(event) => {
if (event.pointerType === "touch") return
setActiveIndex(index())
}}
onClick={() => setActiveIndex(index())}
onFocus={() => setActiveIndex(index())}
onBlur={() => setActiveIndex(undefined)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
setActiveIndex(index())
}}
>
<div
data-slot="model-usage-bar"
style={
{
"--model-usage-fill": `${modelUsageHeight(modelUsageMetricValue(point, props.metric), max())}%`,
} as JSX.CSSProperties
}
/> />
)} <Show when={activeIndex() === index() && activePoint()}>
</For> {(active) => (
</svg> <div
<span data-slot="model-momentum-end" data-state={changeState()} style={chart().endStyle}> data-component="chart-tooltip"
<i /> data-placement={index() > props.data.length * 0.62 ? "left" : "right"}
{formatChange(props.data.tokenChange)} >
</span> <strong>{active().date}</strong>
</div> <span>
<div data-slot="model-momentum-months" aria-hidden="true"> {formatModelUsageValue(active(), props.metric)} {modelUsageLabel(props.metric, i18n)}
<For each={momentumMonthLabels(chart().startDate, props.locale, props.data.updatedAt)}> </span>
{(month) => <span style={{ left: `${month.x}%` }}>{month.label}</span>} <div data-slot="tooltip-divider" />
<p>
<span data-slot="tooltip-label">
<i /> {i18n.t("chart.daily")} {modelUsageLabel(props.metric, i18n)}
</span>
<b>{formatModelUsageValue(active(), props.metric)}</b>
</p>
</div>
)}
</Show>
</div>
)}
</For> </For>
</div> </div>
</div> </div>
) )
} }
function MomentumMetric(props: { label: string; value: string; watermark?: string }) { function modelUsageMetricValue(point: ModelUsagePoint, metric: "tokens" | "users") {
return ( if (metric === "users") return point.users
<div data-component="model-momentum-metric"> return point.tokens
<Show when={props.watermark}>{(watermark) => <em aria-hidden="true">{watermark()}</em>}</Show> }
<span>{props.label}</span>
<strong>{props.value}</strong> function formatModelUsageValue(point: ModelUsagePoint, metric: "tokens" | "users") {
</div> if (metric === "users") return formatUsers(point.users)
) return formatTokens(point.tokens)
}
function modelUsageLabel(metric: "tokens" | "users", i18n: ReturnType<typeof useI18n>) {
if (metric === "users") return i18n.t("format.users")
return i18n.t("format.tokens")
} }
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) { function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
@@ -916,111 +979,19 @@ function formatGeoShare(value: number) {
return `${value.toFixed(value > 0 && value < 1 ? 1 : 0)}%` return `${value.toFixed(value > 0 && value < 1 ? 1 : 0)}%`
} }
function momentumChart(data: ModelUsagePoint[], updatedAt: string | null) { function modelUsageHeight(tokens: number, max: number) {
const fallbackDate = updatedAt ? formatMomentumDateLabel(updatedAt) : "JAN 1" if (tokens <= 0) return 0
const points = return Math.max(2, Math.min(100, (tokens / max) * 100))
data.length > 1 ? data : [data[0] ?? emptyUsagePoint(fallbackDate), data[0] ?? emptyUsagePoint(fallbackDate)]
const max = Math.max(1, ...points.map((point) => point.tokens))
const split = Math.max(1, Math.floor((points.length - 1) / 2))
const coordinates = points.map((point, index) => ({
date: point.date,
tokens: point.tokens,
x: (index / Math.max(1, points.length - 1)) * 1200,
y: 364 - (point.tokens / max) * 364,
}))
const end = coordinates[coordinates.length - 1]
return {
startDate: coordinates[0].date,
endDate: end.date,
previousPath: smoothLinePath(coordinates.slice(0, split + 1)),
currentPath: smoothLinePath(coordinates.slice(split)),
markers: [
{ ...coordinates[0], active: false },
{ ...coordinates[split], active: true },
{ ...end, active: true },
],
endStyle: {
"--momentum-end-x": `${Math.min(94, Math.max(0, ((end.x + 8) / 1200) * 100))}%`,
"--momentum-end-y": `${Math.min(96, Math.max(0, ((end.y - 7) / 370) * 100))}%`,
} as JSX.CSSProperties,
}
} }
function emptyUsagePoint(date: string): ModelUsagePoint { function isModelUsageDense(count: number) {
return { date, tokens: 0, users: 0, sessions: 0, cost: 0 } return count > 20
} }
function smoothLinePath(points: { x: number; y: number }[]) { function isModelUsageLabelHidden(index: number, count: number) {
if (points.length === 0) return "" if (count <= 16) return false
if (points.length === 1) return `M${formatSparklinePoint(points[0].x)} ${formatSparklinePoint(points[0].y)}` const interval = Math.ceil(count / 8)
return points return index !== count - 1 && index % interval !== 0
.map((point, index) => {
if (index === 0) return `M${formatSparklinePoint(point.x)} ${formatSparklinePoint(point.y)}`
const previous = points[index - 1]
const next = points[index + 1] ?? point
const beforePrevious = points[index - 2] ?? previous
const controlStart = {
x: previous.x + (point.x - beforePrevious.x) / 6,
y: previous.y + (point.y - beforePrevious.y) / 6,
}
const controlEnd = {
x: point.x - (next.x - previous.x) / 6,
y: point.y - (next.y - previous.y) / 6,
}
return `C${formatSparklinePoint(controlStart.x)} ${formatSparklinePoint(controlStart.y)} ${formatSparklinePoint(controlEnd.x)} ${formatSparklinePoint(controlEnd.y)} ${formatSparklinePoint(point.x)} ${formatSparklinePoint(point.y)}`
})
.join(" ")
}
function momentumMonthLabels(startDate: string, locale: string, updatedAt: string | null) {
const start = parseMomentumDate(startDate, updatedAt)
const first = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() - 1, 1))
return Array.from({ length: 5 }, (_, index) => {
const date = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + index, 1))
return {
label: new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" }).format(date).toUpperCase(),
x: index === 4 ? 98 : index * 24.5,
}
})
}
function formatMomentumDate(date: string, locale: string, updatedAt: string | null) {
return new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})
.format(parseMomentumDate(date, updatedAt))
.toUpperCase()
}
function parseMomentumDate(date: string, updatedAt: string | null): Date {
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(date)
if (iso) return new Date(Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])))
const label = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(date.trim())
const month = label ? shortMonths.findIndex((item) => item.toLowerCase() === label[1].toLowerCase()) : -1
if (!label || month < 0) return new Date(Date.UTC(1970, 0, 1))
const anchor = updatedAt ? parseMomentumDate(updatedAt, null) : new Date(Date.UTC(1970, 0, 1))
const year = month > anchor.getUTCMonth() + 1 ? anchor.getUTCFullYear() - 1 : anchor.getUTCFullYear()
return new Date(Date.UTC(year, month, Number(label[2])))
}
function formatMomentumDateLabel(date: string) {
const parsed = parseMomentumDate(date, null)
if (parsed.getUTCFullYear() === 1970) return "JAN 1"
return `${shortMonths[parsed.getUTCMonth()]} ${parsed.getUTCDate()}`
}
function formatRankLabel(rank: number | null) {
if (rank === null) return "--"
return `#${String(rank).padStart(2, "0")}`
}
function capitalizeLabel(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
} }
function formatRankMove(change: number) { function formatRankMove(change: number) {
@@ -1079,28 +1050,6 @@ function formatTokens(value: number) {
return String(Math.round(value)) return String(Math.round(value))
} }
function formatCatalogLimit(value: number | undefined, unknown: string) {
return value === undefined ? unknown : formatTokens(value)
}
function formatCatalogMonth(value: string | undefined, locale: string, unknown: string) {
if (!value) return unknown
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
if (!match) return value
return new Intl.DateTimeFormat(locale, {
month: match[2] ? "short" : undefined,
year: "numeric",
timeZone: "UTC",
}).format(new Date(Date.UTC(Number(match[1]), match[2] ? Number(match[2]) - 1 : 0, 1)))
}
function formatCatalogModalities(values: string[], locale: string, unknown: string) {
if (values.length === 0) return unknown
const labels = values.map((value) => value.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()))
if (labels.length === 1) return labels[0] ?? unknown
return new Intl.ListFormat(locale, { style: "long", type: "conjunction" }).format(labels)
}
function formatInteger(value: number) { function formatInteger(value: number) {
return new Intl.NumberFormat("en").format(value) return new Intl.NumberFormat("en").format(value)
} }
+3 -458
View File
@@ -2129,40 +2129,27 @@
} }
[data-page="stats"] [data-component="market-share-list"] li { [data-page="stats"] [data-component="market-share-list"] li {
min-width: 0;
}
[data-page="stats"] [data-component="market-share-list"] li > a,
[data-page="stats"] [data-component="market-share-list"] li > button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%;
min-width: 0; min-width: 0;
height: 28px; height: 28px;
box-sizing: border-box; box-sizing: border-box;
padding: 0 8px; padding: 0 8px;
border: 1px solid var(--stats-line);
background: var(--stats-layer); background: var(--stats-layer);
color: inherit; border: 1px solid var(--stats-line);
font: inherit;
font-size: 11px; font-size: 11px;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
text-decoration: none;
transition: transition:
border-color 120ms ease, border-color 120ms ease,
box-shadow 120ms ease, box-shadow 120ms ease,
background 120ms ease; background 120ms ease;
} }
[data-page="stats"] [data-component="market-share-list"] li > button { [data-page="stats"] [data-component="market-share-list"] li[data-active="true"],
appearance: none; [data-page="stats"] [data-component="market-share-list"] li:focus-visible {
}
[data-page="stats"] [data-component="market-share-list"] li[data-active="true"] > :is(a, button),
[data-page="stats"] [data-component="market-share-list"] li > :is(a, button):focus-visible {
border-color: var(--stats-text); border-color: var(--stats-text);
background: var(--stats-layer); background: var(--stats-layer);
box-shadow: box-shadow:
@@ -3348,320 +3335,6 @@
height: 24px; height: 24px;
} }
[data-page="stats"] [data-section="model-specs"] {
position: relative;
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
min-height: 144px;
color: var(--stats-text);
box-shadow:
inset 0 1px var(--stats-line),
inset 0 -1px var(--stats-line),
inset 1px 0 var(--stats-line),
inset -1px 0 var(--stats-line);
}
[data-page="stats"] [data-component="model-spec"] {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
min-height: 144px;
box-sizing: border-box;
padding: 40px;
border-left: 1px solid var(--stats-line);
}
[data-page="stats"] [data-component="model-spec"]:first-child {
border-left: 0;
}
[data-page="stats"] [data-component="model-spec"] span {
color: var(--stats-muted);
font-size: 13px;
font-weight: 500;
line-height: 1.1;
}
[data-page="stats"] [data-component="model-spec"] strong {
min-width: 0;
overflow: hidden;
color: var(--stats-text);
font-size: 28px;
font-weight: 500;
line-height: 1.5;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-section="model-momentum"] {
position: relative;
box-sizing: border-box;
display: grid;
gap: 40px;
min-height: 938px;
padding: 80px 40px;
color: var(--stats-text);
box-shadow:
inset 1px 0 var(--stats-line),
inset -1px 0 var(--stats-line);
}
[data-page="stats"] [data-slot="model-momentum-title"] {
min-width: 0;
height: 42px;
margin: 0;
overflow: hidden;
color: var(--stats-muted);
font-size: 28px;
font-weight: 400;
line-height: 42px;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-momentum-title"] a {
color: var(--stats-text);
font-weight: 700;
text-decoration: none;
}
[data-page="stats"] [data-slot="model-momentum-title"] a:hover,
[data-page="stats"] [data-slot="model-momentum-title"] a:focus-visible {
color: var(--stats-text);
outline: none;
text-decoration: none;
}
[data-page="stats"] [data-slot="model-momentum-title"] span {
margin-left: 12px;
}
[data-page="stats"] [data-slot="model-momentum-pattern"] {
width: 100%;
height: 16px;
overflow: hidden;
background: var(--stats-line);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: center top;
mask-repeat: repeat;
mask-size: 6px 6px;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: center top;
-webkit-mask-repeat: repeat;
-webkit-mask-size: 6px 6px;
}
[data-page="stats"] [data-component="model-momentum-chart"] {
position: relative;
display: grid;
grid-template-rows: 64px 370px 14px;
gap: 16px;
height: 480px;
min-width: 0;
}
[data-page="stats"] [data-slot="model-momentum-summary"] {
display: grid;
align-content: start;
gap: 8px;
width: fit-content;
min-width: 0;
}
[data-page="stats"] [data-slot="model-momentum-total"] {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
height: 42px;
}
[data-page="stats"] [data-slot="model-momentum-total"] strong {
min-width: 0;
overflow: hidden;
color: var(--stats-text);
font-size: 28px;
font-weight: 700;
line-height: 42px;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-momentum-total"] span,
[data-page="stats"] [data-slot="model-momentum-end"] {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
box-sizing: border-box;
padding: 0 8px;
background: #198b4324;
color: #198b43;
font-size: 13px;
font-weight: 700;
line-height: 14px;
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-momentum-total"] span[data-state="negative"],
[data-page="stats"] [data-slot="model-momentum-end"][data-state="negative"] {
background: #b82d3524;
color: #b82d35;
}
[data-page="stats"] [data-slot="model-momentum-summary"] p {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin: 0;
color: var(--stats-muted);
font-size: 13px;
font-weight: 500;
line-height: 14px;
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-momentum-plot"] {
position: relative;
min-width: 0;
height: 370px;
}
[data-page="stats"] [data-slot="model-momentum-plot"] svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
[data-page="stats"] [data-slot="model-momentum-line-muted"],
[data-page="stats"] [data-slot="model-momentum-line-active"] {
fill: none;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
[data-page="stats"] [data-slot="model-momentum-line-muted"] {
stroke: var(--stats-bar-idle);
}
[data-page="stats"] [data-slot="model-momentum-line-active"] {
stroke: #198b43;
}
[data-page="stats"] [data-slot="model-momentum-marker"] {
fill: var(--stats-bar-idle);
}
[data-page="stats"] [data-slot="model-momentum-marker"][data-active="true"] {
fill: #198b43;
}
[data-page="stats"] [data-slot="model-momentum-end"] {
position: absolute;
top: var(--momentum-end-y);
left: var(--momentum-end-x);
gap: 8px;
height: 14px;
padding: 0;
background: transparent;
transform: translateY(-50%);
}
[data-page="stats"] [data-slot="model-momentum-end"] i {
display: block;
width: 6px;
height: 6px;
background: currentColor;
}
[data-page="stats"] [data-slot="model-momentum-months"] {
position: relative;
height: 14px;
color: var(--stats-muted);
font-size: 13px;
font-weight: 500;
line-height: 14px;
}
[data-page="stats"] [data-slot="model-momentum-months"] span {
position: absolute;
top: 0;
transform: translateX(-50%);
}
[data-page="stats"] [data-slot="model-momentum-months"] span:first-child {
transform: none;
}
[data-page="stats"] [data-slot="model-momentum-months"] span:last-child {
transform: translateX(-100%);
}
[data-page="stats"] [data-slot="model-momentum-metrics"] {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
[data-page="stats"] [data-component="model-momentum-metric"] {
position: relative;
display: grid;
align-content: space-between;
min-width: 0;
min-height: 120px;
box-sizing: border-box;
padding: 16px;
overflow: hidden;
border: 1px solid var(--stats-line);
background:
linear-gradient(180deg, color-mix(in srgb, var(--stats-bg) 76%, transparent), transparent), var(--stats-layer);
}
[data-page="stats"] [data-component="model-momentum-metric"] span {
position: relative;
z-index: 1;
color: var(--stats-muted);
font-size: 13px;
font-weight: 500;
line-height: 14px;
}
[data-page="stats"] [data-component="model-momentum-metric"] strong {
position: relative;
z-index: 1;
min-width: 0;
overflow: hidden;
color: var(--stats-text);
font-size: 28px;
font-weight: 500;
line-height: 42px;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-component="model-momentum-metric"] em {
position: absolute;
top: -90px;
left: -69px;
color: var(--stats-text);
font-size: 200px;
font-style: normal;
font-weight: 700;
line-height: 300px;
opacity: 0.04;
pointer-events: none;
white-space: nowrap;
}
[data-page="stats"] [data-section="lab-overview"] { [data-page="stats"] [data-section="lab-overview"] {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
@@ -5293,7 +4966,6 @@
[data-page="stats"] [data-section="session-cost"], [data-page="stats"] [data-section="session-cost"],
[data-page="stats"] [data-section="model-hero"], [data-page="stats"] [data-section="model-hero"],
[data-page="stats"] [data-section="lab-hero"], [data-page="stats"] [data-section="lab-hero"],
[data-page="stats"] [data-section="model-momentum"],
[data-page="stats"] [data-section="model-panel"] { [data-page="stats"] [data-section="model-panel"] {
padding: 64px 32px; padding: 64px 32px;
} }
@@ -5384,48 +5056,6 @@
min-height: 0; min-height: 0;
} }
[data-page="stats"] [data-section="model-specs"] {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
[data-page="stats"] [data-component="model-spec"] {
min-height: 132px;
padding: 32px;
}
[data-page="stats"] [data-component="model-spec"]:nth-child(3n + 1) {
border-left: 0;
}
[data-page="stats"] [data-component="model-spec"]:nth-child(n + 4) {
border-top: 1px solid var(--stats-line);
}
[data-page="stats"] [data-section="model-momentum"] {
min-height: auto;
}
[data-page="stats"] [data-slot="model-momentum-title"] {
height: auto;
overflow: visible;
line-height: 1.5;
text-overflow: clip;
white-space: normal;
}
[data-page="stats"] [data-component="model-momentum-chart"] {
grid-template-rows: auto 320px 14px;
height: 434px;
}
[data-page="stats"] [data-slot="model-momentum-plot"] {
height: 320px;
}
[data-page="stats"] [data-slot="model-momentum-metrics"] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-page="stats"] [data-component="model-metric-grid"], [data-page="stats"] [data-component="model-metric-grid"],
[data-page="stats"] [data-component="model-metric-grid"][data-variant="dense"], [data-page="stats"] [data-component="model-metric-grid"][data-variant="dense"],
[data-page="stats"] [data-component="lab-model-grid"] { [data-page="stats"] [data-component="lab-model-grid"] {
@@ -5554,7 +5184,6 @@
[data-page="stats"] [data-section="session-cost"], [data-page="stats"] [data-section="session-cost"],
[data-page="stats"] [data-section="model-hero"], [data-page="stats"] [data-section="model-hero"],
[data-page="stats"] [data-section="lab-hero"], [data-page="stats"] [data-section="lab-hero"],
[data-page="stats"] [data-section="model-momentum"],
[data-page="stats"] [data-section="model-panel"] { [data-page="stats"] [data-section="model-panel"] {
padding: 48px 24px; padding: 48px 24px;
} }
@@ -5722,90 +5351,6 @@
line-height: 22px; line-height: 22px;
} }
[data-page="stats"] [data-section="model-specs"] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-page="stats"] [data-component="model-spec"] {
min-height: 116px;
padding: 24px;
}
[data-page="stats"] [data-component="model-spec"]:nth-child(n) {
border-left: 1px solid var(--stats-line);
border-top: 0;
}
[data-page="stats"] [data-component="model-spec"]:nth-child(2n + 1) {
border-left: 0;
}
[data-page="stats"] [data-component="model-spec"]:nth-child(n + 3) {
border-top: 1px solid var(--stats-line);
}
[data-page="stats"] [data-component="model-spec"]:last-child:nth-child(odd) {
grid-column: 1 / -1;
}
[data-page="stats"] [data-component="model-spec"] strong {
font-size: 24px;
line-height: 1.35;
}
[data-page="stats"] [data-section="model-momentum"] {
gap: 28px;
}
[data-page="stats"] [data-slot="model-momentum-title"] {
font-size: 20px;
line-height: 30px;
}
[data-page="stats"] [data-slot="model-momentum-title"] span {
margin-left: 8px;
}
[data-page="stats"] [data-component="model-momentum-chart"] {
grid-template-rows: auto 260px 14px;
gap: 14px;
height: 380px;
}
[data-page="stats"] [data-slot="model-momentum-total"] {
align-items: flex-start;
flex-direction: column;
gap: 6px;
height: auto;
}
[data-page="stats"] [data-slot="model-momentum-total"] strong {
font-size: 24px;
line-height: 32px;
}
[data-page="stats"] [data-slot="model-momentum-summary"] p {
flex-wrap: wrap;
white-space: normal;
}
[data-page="stats"] [data-slot="model-momentum-plot"] {
height: 260px;
}
[data-page="stats"] [data-slot="model-momentum-end"] {
display: none;
}
[data-page="stats"] [data-component="model-momentum-metric"] {
min-height: 112px;
}
[data-page="stats"] [data-component="model-momentum-metric"] strong {
font-size: clamp(20px, 5.5vw, 24px);
line-height: 34px;
}
[data-page="stats"] [data-slot="model-catalog-grid"] { [data-page="stats"] [data-slot="model-catalog-grid"] {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
+21 -47
View File
@@ -1262,56 +1262,30 @@ function MarketShareList(props: {
onActiveAuthorChange: (author: string) => void onActiveAuthorChange: (author: string) => void
}) { }) {
const i18n = useI18n() const i18n = useI18n()
const language = useLanguage()
return ( return (
<ol data-component="market-share-list"> <ol data-component="market-share-list">
<For each={props.data}> <For each={props.data}>
{(item, index) => { {(item, index) => (
const label = () => <li
`${item.author} ${formatTrillions(item.tokens)} ${item.share.toFixed(1)} ${i18n.t("chart.percent")}` role="button"
const content = () => ( tabIndex={0}
<> aria-label={`${item.author} ${formatTrillions(item.tokens)} ${item.share.toFixed(1)} ${i18n.t("chart.percent")}`}
<span>{String(index() + 1).padStart(2, "0")}</span> data-active={props.activeAuthor === item.author ? "true" : undefined}
<i style={{ background: getRankColor(item.author, index(), props.authorOrder, marketColors) }} /> onPointerEnter={() => props.onActiveAuthorChange(item.author)}
<strong>{item.author}</strong> onFocus={() => props.onActiveAuthorChange(item.author)}
<em>{formatTrillions(item.tokens)}</em> onKeyDown={(event) => {
<b>{item.share.toFixed(1)}%</b> if (event.key !== "Enter" && event.key !== " ") return
</> event.preventDefault()
) props.onActiveAuthorChange(item.author)
const href = () => }}
item.author === "Other" ? undefined : language.route(`${import.meta.env.BASE_URL}${modelSlug(item.author)}`) >
return ( <span>{String(index() + 1).padStart(2, "0")}</span>
<li <i style={{ background: getRankColor(item.author, index(), props.authorOrder, marketColors) }} />
data-active={props.activeAuthor === item.author ? "true" : undefined} <strong>{item.author}</strong>
onPointerEnter={() => props.onActiveAuthorChange(item.author)} <em>{formatTrillions(item.tokens)}</em>
> <b>{item.share.toFixed(1)}%</b>
<Show </li>
when={href()} )}
fallback={
<button
type="button"
aria-label={label()}
onClick={() => props.onActiveAuthorChange(item.author)}
onFocus={() => props.onActiveAuthorChange(item.author)}
>
{content()}
</button>
}
>
{(href) => (
<a
href={href()}
aria-label={label()}
onClick={() => props.onActiveAuthorChange(item.author)}
onFocus={() => props.onActiveAuthorChange(item.author)}
>
{content()}
</a>
)}
</Show>
</li>
)
}}
</For> </For>
</ol> </ol>
) )

Some files were not shown because too many files have changed in this diff Show More