mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac384f3d27 | |||
| 92a50f2397 | |||
| ac7ee99a24 |
@@ -1,3 +0,0 @@
|
||||
node_modules
|
||||
.remotion
|
||||
out/frame-*.png
|
||||
@@ -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.
@@ -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 |
@@ -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
|
||||
@@ -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 22–28, 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 22–28, 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>
|
||||
)
|
||||
}
|
||||
@@ -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 22–28, 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>
|
||||
)
|
||||
}
|
||||
@@ -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 22–28, 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>
|
||||
)
|
||||
}
|
||||
@@ -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 12–25, 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>
|
||||
)
|
||||
}
|
||||
@@ -19,12 +19,13 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"node-gyp": "12.4.0",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "catalog:",
|
||||
"turbo": "2.10.2",
|
||||
"turbo": "2.8.13",
|
||||
},
|
||||
},
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -4533,7 +4522,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -5333,7 +5322,19 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -5371,7 +5372,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -5573,7 +5574,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -5621,8 +5622,6 @@
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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/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/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/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/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/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/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-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/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"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=="],
|
||||
@@ -6353,9 +6362,7 @@
|
||||
|
||||
"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/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
"node-gyp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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/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=="],
|
||||
|
||||
"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/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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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/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/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=="],
|
||||
|
||||
"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/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/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/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=",
|
||||
"aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=",
|
||||
"aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=",
|
||||
"x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0="
|
||||
"x86_64-linux": "sha256-ZmAIcG3wPgssKejLe/EMJOjPuMHCyRiiItRvSqIjaME=",
|
||||
"aarch64-linux": "sha256-lbAHoVI/CvgdrfKZ/9dKZ/O7DxJvuWEOrDgcCuFGPBk=",
|
||||
"aarch64-darwin": "sha256-Nrs4ijvzQ3J8AYigJSlsr66feUvLZ5iiYCfBdaZNJEc=",
|
||||
"x86_64-darwin": "sha256-ltCaJSO7kZWuMOU49269xvboNVX35OJDFj0gXCDf62Y="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -99,12 +99,13 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"node-gyp": "12.4.0",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "catalog:",
|
||||
"turbo": "2.10.2"
|
||||
"turbo": "2.8.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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 {
|
||||
Component,
|
||||
ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
JSX,
|
||||
onCleanup,
|
||||
Show,
|
||||
ValidComponent,
|
||||
} from "solid-js"
|
||||
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
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 { useLanguage } from "@/context/language"
|
||||
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) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
@@ -255,9 +242,14 @@ export function ModelSelectorPopoverV2(props: {
|
||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||
)
|
||||
const models = createMemo(() => {
|
||||
const search = store.search.trim()
|
||||
const search = store.search.trim().toLowerCase()
|
||||
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()
|
||||
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
@@ -341,23 +333,19 @@ export function ModelSelectorPopoverV2(props: {
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim()
|
||||
const search = value.trim().toLowerCase()
|
||||
const first = [...allModels()]
|
||||
.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 })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.open) return
|
||||
createEventListener(
|
||||
document,
|
||||
"keydown",
|
||||
(event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
|
||||
@@ -218,7 +218,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let scrollRef!: HTMLDivElement
|
||||
let slashPopoverRef!: HTMLDivElement
|
||||
let restoreEndOnFocus = true
|
||||
let savedCursor: number | null = null
|
||||
|
||||
const mirror = { input: false }
|
||||
const inset = 56
|
||||
@@ -591,7 +590,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const restoreFocus = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current())
|
||||
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, cursor)
|
||||
queueScroll()
|
||||
@@ -628,7 +627,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||
|
||||
const handleBlur = () => {
|
||||
savedCursor = currentCursor()
|
||||
closePopover()
|
||||
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 { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -8,7 +8,6 @@ import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
|
||||
export type PromptProject = {
|
||||
name?: string
|
||||
@@ -102,16 +101,6 @@ export function createPromptProjectController(input: {
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
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 {
|
||||
selected,
|
||||
@@ -138,7 +127,16 @@ export function createPromptProjectController(input: {
|
||||
}
|
||||
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() {
|
||||
setStore({ search: "", active: initialActive() })
|
||||
setTimeout(() => searchRef?.focus())
|
||||
@@ -172,9 +170,6 @@ export function createPromptProjectController(input: {
|
||||
focusSearch() {
|
||||
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
|
||||
}
|
||||
|
||||
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 (
|
||||
<DropdownMenu
|
||||
open={props.controller.open()}
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createSignal, Show, type JSXElement } from "solid-js"
|
||||
import "./titlebar-tab-popover.css"
|
||||
|
||||
// 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).
|
||||
const CLOSE_DELAY = 0
|
||||
// After a preview closes, hovering a neighbouring tab within this window skips
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createEffect, createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { createServerProjects, ServerConnection, useServer } from "./server"
|
||||
import { useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
@@ -128,14 +127,6 @@ function createServerCtx(
|
||||
}
|
||||
|
||||
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 =
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
@@ -148,7 +139,6 @@ function createServerCtx(
|
||||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
@@ -494,7 +493,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const root = rootFor(project.worktree)
|
||||
if (root === project.worktree) continue
|
||||
|
||||
server.projects.remove(project.worktree)
|
||||
server.projects.close(project.worktree)
|
||||
|
||||
if (!seen.has(root)) {
|
||||
server.projects.open(root)
|
||||
@@ -614,14 +613,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
projects: {
|
||||
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) {
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("createServerProjects", () => {
|
||||
test("keeps active and explicit server buckets in one reactive store", () => {
|
||||
createRoot((dispose) => {
|
||||
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 remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
|
||||
|
||||
@@ -115,88 +115,6 @@ describe("createServerProjects", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -2,23 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { type Accessor, batch, createMemo } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
type ServerProjectState = {
|
||||
projects: Record<string, StoredProject[]>
|
||||
lastProject: Record<string, string>
|
||||
recentlyClosed: Record<string, string[]>
|
||||
}
|
||||
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
|
||||
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) {
|
||||
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 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 {
|
||||
list: current,
|
||||
recentlyClosed: currentClosed,
|
||||
remove,
|
||||
open(directory: string) {
|
||||
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
|
||||
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) {
|
||||
remove(directory)
|
||||
const key = pathKey(directory)
|
||||
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
|
||||
0,
|
||||
RECENTLY_CLOSED_HISTORY_LIMIT,
|
||||
setStore(
|
||||
"projects",
|
||||
input.scope(),
|
||||
current().filter((project) => project.worktree !== directory),
|
||||
)
|
||||
setStore("recentlyClosed", input.scope(), closed)
|
||||
},
|
||||
expand(directory: string) {
|
||||
const index = current().findIndex((project) => project.worktree === directory)
|
||||
@@ -269,7 +235,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
list: [] as StoredServer[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
recentlyClosed: {} as Record<string, string[]>,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -604,7 +604,6 @@ export const dict = {
|
||||
"home.title": "Home",
|
||||
"home.projects": "Projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.recentlyClosed": "Recently closed",
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
"home.server.expand": "Expand server projects",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
|
||||
@@ -270,10 +270,6 @@ export function NewHome() {
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
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 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">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
recentlyClosed={recentlyClosed()}
|
||||
homedir={homedir()}
|
||||
selected={selection()}
|
||||
focusServer={focusServer}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
||||
chooseProject={(conn) => void chooseProject(conn)}
|
||||
editProject={editProject}
|
||||
closeProject={(conn, directory) => {
|
||||
@@ -645,13 +638,10 @@ export function NewHome() {
|
||||
|
||||
function HomeProjectColumn(props: {
|
||||
projects: LocalProject[]
|
||||
recentlyClosed: LocalProject[]
|
||||
homedir: string
|
||||
selected: HomeProjectSelection
|
||||
focusServer: (server: ServerConnection.Any) => void
|
||||
selectProject: (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
|
||||
editProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
closeProject: (server: ServerConnection.Any, directory: string) => void
|
||||
@@ -680,10 +670,8 @@ function HomeProjectColumn(props: {
|
||||
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="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show
|
||||
when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
|
||||
>
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
@@ -703,21 +691,7 @@ function HomeProjectColumn(props: {
|
||||
when={global.servers.list().length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<Show
|
||||
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>
|
||||
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||
</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: {
|
||||
project: LocalProject
|
||||
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))
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={name()}
|
||||
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1393,14 +1393,14 @@ export function MessageTimeline(props: {
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
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"
|
||||
>
|
||||
/
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { useSessionLayout } from "./session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
|
||||
@@ -27,23 +26,9 @@ export const useComposerCommands = () => {
|
||||
|
||||
const chooseModel = async () => {
|
||||
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")
|
||||
owner.run(() => {
|
||||
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
|
||||
void dialog.show(() => <DialogSelectModel model={local.model} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1955,7 +1955,6 @@ export type ModelsListOutput = {
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "native"
|
||||
readonly package?: string
|
||||
readonly url?: string
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
@@ -2011,12 +2010,7 @@ export type ProvidersListOutput = {
|
||||
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 type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
||||
readonly request: {
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
@@ -2049,12 +2043,7 @@ export type ProvidersGetOutput = {
|
||||
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 type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
||||
readonly request: {
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
|
||||
+35
-36
@@ -8,11 +8,11 @@ The package is currently private to this workspace. Its API is designed around t
|
||||
|
||||
```ts
|
||||
// One execution
|
||||
yield * CodeMode.execute({ tools, code })
|
||||
yield* CodeMode.execute({ tools, code })
|
||||
|
||||
// A reusable runtime
|
||||
const runtime = CodeMode.make({ tools, limits })
|
||||
yield * runtime.execute(code)
|
||||
yield* runtime.execute(code)
|
||||
|
||||
// One agent-facing code tool
|
||||
const codeTool = runtime.agentTool()
|
||||
@@ -55,9 +55,7 @@ const runtime = CodeMode.make({
|
||||
},
|
||||
})
|
||||
|
||||
const result =
|
||||
yield *
|
||||
runtime.execute(`
|
||||
const result = yield* runtime.execute(`
|
||||
const order = await tools.orders.lookup({ id: "order_42" })
|
||||
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
|
||||
const tool = Tool.make({
|
||||
description,
|
||||
input, // Effect Schema (validating) or JSON Schema (render-only)
|
||||
output, // optional; same choice
|
||||
input, // Effect Schema (validating) or JSON Schema (render-only)
|
||||
output, // optional; same choice
|
||||
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:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
CodeMode.execute({
|
||||
tools: { orders: { lookup: lookupOrder } },
|
||||
code: `return await tools.orders.lookup({ id: "order_42" })`,
|
||||
limits: { maxToolCalls: 10 },
|
||||
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
|
||||
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
|
||||
})
|
||||
const result = yield* CodeMode.execute({
|
||||
tools: { orders: { lookup: lookupOrder } },
|
||||
code: `return await tools.orders.lookup({ id: "order_42" })`,
|
||||
limits: { maxToolCalls: 10 },
|
||||
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.
|
||||
@@ -114,10 +110,10 @@ const runtime = CodeMode.make({
|
||||
limits: { timeoutMs: 30_000 },
|
||||
})
|
||||
|
||||
runtime.catalog() // structured tool descriptions
|
||||
runtime.instructions() // model-facing syntax and tool guide
|
||||
runtime.catalog() // structured tool descriptions
|
||||
runtime.instructions() // model-facing syntax and tool guide
|
||||
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()`.
|
||||
@@ -226,10 +222,10 @@ CodeMode is an orchestration language, not a general JavaScript runtime.
|
||||
|
||||
The limits are exactly three knobs:
|
||||
|
||||
| Limit | Default | Bounds |
|
||||
| ---------------- | -------------------: | -------------------------------------------------------------------- |
|
||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||
| Limit | Default | Bounds |
|
||||
| --- | ---: | --- |
|
||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||
| `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.
|
||||
@@ -258,25 +254,28 @@ Two interpreter internals are fixed constants rather than knobs: at most 8 tool
|
||||
|
||||
Failures are data:
|
||||
|
||||
| Kind | Meaning |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `ParseError` | Source is empty or cannot be parsed. |
|
||||
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
|
||||
| `UnknownTool` | A program referenced a tool the host did not provide. |
|
||||
| `InvalidToolInput` | Tool input 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). |
|
||||
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
|
||||
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
|
||||
| `ToolFailure` | A tool refused or failed. |
|
||||
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
||||
| Kind | Meaning |
|
||||
| --- | --- |
|
||||
| `ParseError` | Source is empty or cannot be parsed. |
|
||||
| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. |
|
||||
| `UnknownTool` | A program referenced a tool the host did not provide. |
|
||||
| `InvalidToolInput` | Tool input 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). |
|
||||
| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. |
|
||||
| `TimeoutExceeded` | Execution exceeded `timeoutMs`. |
|
||||
| `ToolFailure` | A tool refused or failed. |
|
||||
| `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`:
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
+374
-404
@@ -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.
|
||||
|
||||
### Core direction
|
||||
|
||||
- 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).
|
||||
- **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.
|
||||
|
||||
### MCP / tools
|
||||
|
||||
- The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary
|
||||
`Tool.make(...)` definitions and hands CodeMode a plain tool tree.
|
||||
- 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.
|
||||
|
||||
### Discovery / search
|
||||
|
||||
- **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 }`
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
### Schemas / Tool.make
|
||||
|
||||
- `Tool.make` carries rich metadata so search can render real signatures.
|
||||
- Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially
|
||||
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.
|
||||
|
||||
### Attachments / output
|
||||
|
||||
- **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
|
||||
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.
|
||||
|
||||
### Runtime behavior
|
||||
|
||||
- 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
|
||||
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
|
||||
output. Not a tool call; costs no tool budget.
|
||||
- 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
|
||||
Wave 2).
|
||||
|
||||
@@ -154,9 +148,8 @@ and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and
|
||||
`test/tool/registry.test.ts`).
|
||||
|
||||
### Wave 0 - scaffold (done)
|
||||
|
||||
- `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:`,
|
||||
`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).
|
||||
@@ -164,7 +157,6 @@ tool-error,tool-runtime}.ts`, README, AGENTS.md, tests.
|
||||
Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`.
|
||||
|
||||
### Wave 1a - forgiving JS semantics (done)
|
||||
|
||||
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:
|
||||
|
||||
@@ -181,7 +173,6 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t
|
||||
null/undefined still throws (real JS throws too).
|
||||
|
||||
### Wave 1b-i - stdlib value types: Date, RegExp, Map, Set (done)
|
||||
|
||||
`src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both
|
||||
`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.
|
||||
|
||||
### Wave 2 - API layer (done)
|
||||
|
||||
The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this
|
||||
wave; both packages typecheck clean.
|
||||
|
||||
- **`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
|
||||
(render-only - no validation, values pass through; rendering handles `$defs`/`definitions`
|
||||
- `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
|
||||
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
|
||||
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
|
||||
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
|
||||
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
|
||||
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
|
||||
cosmetic, fixed in Wave 4.
|
||||
+ `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
|
||||
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
|
||||
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
|
||||
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
|
||||
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
|
||||
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
|
||||
cosmetic, fixed in Wave 4.
|
||||
- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output`
|
||||
global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields,
|
||||
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
|
||||
fire no end event (timeout kills the whole execution anyway).
|
||||
- **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
|
||||
later deleted that type and the internal limit system entirely.
|
||||
- **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in
|
||||
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
|
||||
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
|
||||
`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 -
|
||||
truncation is now the only result-size mechanism.)
|
||||
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
|
||||
`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 -
|
||||
truncation is now the only result-size mechanism.)
|
||||
- **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
|
||||
(`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged.
|
||||
|
||||
### Wave 3 - OpenCode MCP adapter (done)
|
||||
|
||||
`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
|
||||
`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`
|
||||
availability all come from this package and stay consistent with execution.
|
||||
- **`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,
|
||||
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
|
||||
@@ -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
|
||||
`maxDataBytes` budget that existed at the time was deleted in Fix 5).
|
||||
- **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
|
||||
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)
|
||||
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);
|
||||
@@ -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).
|
||||
|
||||
### Wave 4 - instructions/prompting + polish (done)
|
||||
|
||||
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
|
||||
packages typecheck clean.
|
||||
@@ -333,7 +321,7 @@ packages typecheck clean.
|
||||
exported); `CodeMode.execute` (one-shot) passes it too, preserving the
|
||||
`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
|
||||
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.
|
||||
- **Prompting content** in `instructions()`, mapping 1:1 to the section 5 transcript failures:
|
||||
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`,
|
||||
the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory,
|
||||
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
|
||||
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
|
||||
@@ -364,7 +352,6 @@ packages typecheck clean.
|
||||
images, output truncation.
|
||||
|
||||
### Wave 5 - Promise generalization (done)
|
||||
|
||||
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
|
||||
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).
|
||||
- **`## Workflow`**: numbered steps - find a tool via `tools.$codemode.search` -> read
|
||||
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
|
||||
`## Available tools`" and the steps renumber (4 instead of 5).
|
||||
- **`## 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
|
||||
budget; namespaces must always be present):
|
||||
|
||||
- `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.
|
||||
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
|
||||
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
|
||||
- stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
|
||||
- `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.
|
||||
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
|
||||
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
|
||||
+ 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
|
||||
namespace always appears with its tool count, even at budget 0 (asserted in package and
|
||||
adapter tests).
|
||||
- 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
|
||||
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;
|
||||
worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens.
|
||||
- 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
|
||||
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;
|
||||
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
|
||||
configurable knobs; the internal limit system dies):
|
||||
|
||||
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
|
||||
the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
|
||||
timeoutMs >= 1, others >= 0, RangeError otherwise) is now
|
||||
the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
|
||||
`ResolvedExecutionLimits` shrank to those three fields; the `@internal`
|
||||
`InternalExecutionLimits` type is deleted.
|
||||
- **Deleted outright**: `maxOperations` and the whole operation-budget machinery
|
||||
(`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/
|
||||
`cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check);
|
||||
`maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`,
|
||||
the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes`
|
||||
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
|
||||
audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged);
|
||||
`maxCollectionLength` (every array-length/object-field-count check - this knob was
|
||||
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
|
||||
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
|
||||
`ExecuteResultSchema` (fine - the package is unreleased).
|
||||
- **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
|
||||
only because it produces a clearer error than a native stack-overflow RangeError; still
|
||||
`InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone -
|
||||
`copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just
|
||||
`(tools, maxToolCalls, hooks?, searchIndex?)`.
|
||||
- **Verified fact**: timeout interruption does NOT depend on the operation budget - the
|
||||
Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts
|
||||
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
|
||||
test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` ->
|
||||
`TimeoutExceeded`, elapsed well under a few seconds).
|
||||
- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks +
|
||||
`rejectCircularInsertion` on mutations), plain-objects-only, blocked properties
|
||||
(`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit
|
||||
behaviors unchanged.
|
||||
- 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
|
||||
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
|
||||
native stack during a walk still normalize to the existing "Execution exceeded the
|
||||
maximum nesting depth." data diagnostic - failures remain data everywhere.
|
||||
- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2,
|
||||
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
|
||||
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
|
||||
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
|
||||
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
|
||||
suites: 34 + 16.
|
||||
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
|
||||
the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
|
||||
timeoutMs >= 1, others >= 0, RangeError otherwise) is now
|
||||
the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
|
||||
`ResolvedExecutionLimits` shrank to those three fields; the `@internal`
|
||||
`InternalExecutionLimits` type is deleted.
|
||||
- **Deleted outright**: `maxOperations` and the whole operation-budget machinery
|
||||
(`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/
|
||||
`cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check);
|
||||
`maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`,
|
||||
the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes`
|
||||
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
|
||||
audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged);
|
||||
`maxCollectionLength` (every array-length/object-field-count check - this knob was
|
||||
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
|
||||
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
|
||||
`ExecuteResultSchema` (fine - the package is unreleased).
|
||||
- **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
|
||||
only because it produces a clearer error than a native stack-overflow RangeError; still
|
||||
`InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone -
|
||||
`copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just
|
||||
`(tools, maxToolCalls, hooks?, searchIndex?)`.
|
||||
- **Verified fact**: timeout interruption does NOT depend on the operation budget - the
|
||||
Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts
|
||||
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
|
||||
test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` ->
|
||||
`TimeoutExceeded`, elapsed well under a few seconds).
|
||||
- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks +
|
||||
`rejectCircularInsertion` on mutations), plain-objects-only, blocked properties
|
||||
(`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit
|
||||
behaviors unchanged.
|
||||
- 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
|
||||
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
|
||||
native stack during a walk still normalize to the existing "Execution exceeded the
|
||||
maximum nesting depth." data diagnostic - failures remain data everywhere.
|
||||
- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2,
|
||||
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
|
||||
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
|
||||
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
|
||||
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
|
||||
suites: 34 + 16.
|
||||
|
||||
**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 /
|
||||
@@ -654,200 +639,196 @@ adapter suites: 34 + 16.
|
||||
**Fix 8 - condensed instructions + round-robin catalog fairness + plural-aware search**
|
||||
(user direction: the fixed instruction prose was too verbose; two discovery fixes ride
|
||||
along). All in `tool-runtime.ts`; no interpreter changes.
|
||||
|
||||
- **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
|
||||
only what is unusual or missing": (1) standard modern JS works - functions/closures,
|
||||
destructuring, template literals, loops, try/catch, spread, optional chaining, the
|
||||
usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and
|
||||
Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are
|
||||
stripped before execution, decorators are not supported; (3) NOT supported (each fails
|
||||
with a message naming the alternative): classes, generators, for await...of,
|
||||
.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 ->
|
||||
ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter
|
||||
before writing: probed empirically - classes/generators/for-await/.then/.catch/
|
||||
.finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged
|
||||
templates/object getters all fail with clear diagnostics; TS annotations/`as`/
|
||||
interfaces/type aliases are stripped and TS **enums actually work** (transpileModule
|
||||
compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned.
|
||||
`supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched.
|
||||
- **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
|
||||
compliance-driving justifications inline: "most tools return JSON as a string", "raw
|
||||
payloads get truncated and waste context"); Rules keeps only bullets adding new
|
||||
content - filter/aggregate collections in code, console.\* intermediates (logs ride
|
||||
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
|
||||
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
|
||||
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
|
||||
as-is rather than guessing segments".
|
||||
- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0,
|
||||
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
|
||||
(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
|
||||
absorbed the deduped parse/return-small justifications.
|
||||
- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss
|
||||
behavior (alphabetically-late namespaces starved to "none shown" while an early
|
||||
namespace inlines everything) is replaced by round-robin fairness - in each round
|
||||
(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
|
||||
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:
|
||||
`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
|
||||
header, alphabetical namespace order in the output, cheapest-first within each
|
||||
namespace's shown set.
|
||||
- **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
|
||||
"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
|
||||
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
|
||||
description match (path substring 8 + searchable 2 > description 4 + searchable 2).
|
||||
- **Tests**: package instruction/structure assertions updated to the new text; new
|
||||
syntax-section test (leads with "Standard modern JavaScript works", names the
|
||||
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
|
||||
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
|
||||
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)` +
|
||||
its inlined line) - it was "none shown" under starvation. README updated (budgeted
|
||||
catalog paragraph -> round-robin; search paragraph -> singular variants;
|
||||
instructions-structure paragraph -> new section contents). Package suite: 169 pass /
|
||||
0 fail; adapter suites: 34 + 16.
|
||||
- **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
|
||||
only what is unusual or missing": (1) standard modern JS works - functions/closures,
|
||||
destructuring, template literals, loops, try/catch, spread, optional chaining, the
|
||||
usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and
|
||||
Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are
|
||||
stripped before execution, decorators are not supported; (3) NOT supported (each fails
|
||||
with a message naming the alternative): classes, generators, for await...of,
|
||||
.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 ->
|
||||
ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter
|
||||
before writing: probed empirically - classes/generators/for-await/.then/.catch/
|
||||
.finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged
|
||||
templates/object getters all fail with clear diagnostics; TS annotations/`as`/
|
||||
interfaces/type aliases are stripped and TS **enums actually work** (transpileModule
|
||||
compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned.
|
||||
`supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched.
|
||||
- **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
|
||||
compliance-driving justifications inline: "most tools return JSON as a string", "raw
|
||||
payloads get truncated and waste context"); Rules keeps only bullets adding new
|
||||
content - filter/aggregate collections in code, console.* intermediates (logs ride
|
||||
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
|
||||
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
|
||||
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
|
||||
as-is rather than guessing segments".
|
||||
- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0,
|
||||
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
|
||||
(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
|
||||
absorbed the deduped parse/return-small justifications.
|
||||
- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss
|
||||
behavior (alphabetically-late namespaces starved to "none shown" while an early
|
||||
namespace inlines everything) is replaced by round-robin fairness - in each round
|
||||
(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
|
||||
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:
|
||||
`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
|
||||
header, alphabetical namespace order in the output, cheapest-first within each
|
||||
namespace's shown set.
|
||||
- **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
|
||||
"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
|
||||
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
|
||||
description match (path substring 8 + searchable 2 > description 4 + searchable 2).
|
||||
- **Tests**: package instruction/structure assertions updated to the new text; new
|
||||
syntax-section test (leads with "Standard modern JavaScript works", names the
|
||||
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
|
||||
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
|
||||
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)` +
|
||||
its inlined line) - it was "none shown" under starvation. README updated (budgeted
|
||||
catalog paragraph -> round-robin; search paragraph -> singular variants;
|
||||
instructions-structure paragraph -> new section contents). Package suite: 169 pass /
|
||||
0 fail; adapter suites: 34 + 16.
|
||||
|
||||
**Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed
|
||||
instructions and directed further cuts):
|
||||
|
||||
- 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).
|
||||
- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single
|
||||
`unknown`-treatment warning: "A result typed `Promise<unknown>` has no guaranteed
|
||||
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
|
||||
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
|
||||
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
|
||||
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).
|
||||
- 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
|
||||
as a next-iteration follow-up below.
|
||||
- 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).
|
||||
- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single
|
||||
`unknown`-treatment warning: "A result typed `Promise<unknown>` has no guaranteed
|
||||
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
|
||||
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
|
||||
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
|
||||
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).
|
||||
- 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
|
||||
as a next-iteration follow-up below.
|
||||
|
||||
**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):
|
||||
|
||||
- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`,
|
||||
`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are
|
||||
bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` ->
|
||||
`"function"`). Error values stay the same plain `{ name, message }` null-prototype
|
||||
objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol
|
||||
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
|
||||
brand is lost on spread/boundary copies exactly like JS loses the prototype.
|
||||
`caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so
|
||||
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
|
||||
JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set
|
||||
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
|
||||
program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown
|
||||
identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`,
|
||||
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
|
||||
without a specific analogue stay `"Error"` - internal class names never leak. Specific
|
||||
names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS.
|
||||
The operator is handled in `evaluateBinaryExpression`
|
||||
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
|
||||
own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes),
|
||||
`Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and
|
||||
`Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a
|
||||
catchable error naming the recognized constructors.
|
||||
- **Array methods**: `splice` (mutating, returns the removed elements; insertions run
|
||||
`rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined
|
||||
delete count removes nothing), `fill` (circular-checked value) and `copyWithin`
|
||||
(host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set
|
||||
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
|
||||
array properties still read `undefined`.
|
||||
- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host
|
||||
default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form
|
||||
-> catchable error naming the four valid forms), `trimLeft`/`trimRight` as
|
||||
trimStart/trimEnd aliases.
|
||||
- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the
|
||||
offending pattern (or flags) plus the engine reason (deduped "Invalid regular
|
||||
expression:" prefix via `regexFailureReason`) and a shared escaping hint
|
||||
(`escapeRegexHint`); flags failures list the valid flag letters; the
|
||||
replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and
|
||||
the single-match alternative.
|
||||
- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues =
|
||||
false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox
|
||||
checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template
|
||||
interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`,
|
||||
which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by
|
||||
reference as leaves** (contents not walked - Map/Set members are validated at their
|
||||
mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity,
|
||||
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
|
||||
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
|
||||
still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on
|
||||
the preserving path are defensively wrapped into sandbox equivalents. Ripple: the
|
||||
`Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`,
|
||||
assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props
|
||||
there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the
|
||||
template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread
|
||||
already preserved instances (reference copies, no checkpoint) - now tested.
|
||||
- **Console formatting**: `formatConsoleArgument` is total and deep
|
||||
(`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity`
|
||||
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
|
||||
date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become
|
||||
in-place `[CodeMode reference]` markers instead of collapsing the whole argument,
|
||||
cycles render `[Circular]` (reachable via Map/Set members, which mutation never
|
||||
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
|
||||
`containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell
|
||||
walkers treat sandbox values as scalar cells.
|
||||
- **Prose**: the instructions Syntax not-supported line dropped its `instanceof
|
||||
Error`/splice mentions (nothing else reworded); README updated (checkpoint
|
||||
preservation vs boundary serialization, error values/`instanceof`, new array/string
|
||||
methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists
|
||||
supported syntax, was already non-exhaustive, and stays accurate).
|
||||
- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name
|
||||
coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias
|
||||
describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib
|
||||
`instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console
|
||||
rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged
|
||||
(34 + 16, green); both packages `tsgo --noEmit` clean.
|
||||
- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`,
|
||||
`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are
|
||||
bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` ->
|
||||
`"function"`). Error values stay the same plain `{ name, message }` null-prototype
|
||||
objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol
|
||||
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
|
||||
brand is lost on spread/boundary copies exactly like JS loses the prototype.
|
||||
`caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so
|
||||
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
|
||||
JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set
|
||||
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
|
||||
program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown
|
||||
identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`,
|
||||
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
|
||||
without a specific analogue stay `"Error"` - internal class names never leak. Specific
|
||||
names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS.
|
||||
The operator is handled in `evaluateBinaryExpression`
|
||||
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
|
||||
own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes),
|
||||
`Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and
|
||||
`Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a
|
||||
catchable error naming the recognized constructors.
|
||||
- **Array methods**: `splice` (mutating, returns the removed elements; insertions run
|
||||
`rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined
|
||||
delete count removes nothing), `fill` (circular-checked value) and `copyWithin`
|
||||
(host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set
|
||||
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
|
||||
array properties still read `undefined`.
|
||||
- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host
|
||||
default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form
|
||||
-> catchable error naming the four valid forms), `trimLeft`/`trimRight` as
|
||||
trimStart/trimEnd aliases.
|
||||
- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the
|
||||
offending pattern (or flags) plus the engine reason (deduped "Invalid regular
|
||||
expression:" prefix via `regexFailureReason`) and a shared escaping hint
|
||||
(`escapeRegexHint`); flags failures list the valid flag letters; the
|
||||
replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and
|
||||
the single-match alternative.
|
||||
- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues =
|
||||
false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox
|
||||
checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template
|
||||
interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`,
|
||||
which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by
|
||||
reference as leaves** (contents not walked - Map/Set members are validated at their
|
||||
mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity,
|
||||
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
|
||||
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
|
||||
still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on
|
||||
the preserving path are defensively wrapped into sandbox equivalents. Ripple: the
|
||||
`Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`,
|
||||
assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props
|
||||
there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the
|
||||
template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread
|
||||
already preserved instances (reference copies, no checkpoint) - now tested.
|
||||
- **Console formatting**: `formatConsoleArgument` is total and deep
|
||||
(`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity`
|
||||
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
|
||||
date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become
|
||||
in-place `[CodeMode reference]` markers instead of collapsing the whole argument,
|
||||
cycles render `[Circular]` (reachable via Map/Set members, which mutation never
|
||||
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
|
||||
`containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell
|
||||
walkers treat sandbox values as scalar cells.
|
||||
- **Prose**: the instructions Syntax not-supported line dropped its `instanceof
|
||||
Error`/splice mentions (nothing else reworded); README updated (checkpoint
|
||||
preservation vs boundary serialization, error values/`instanceof`, new array/string
|
||||
methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists
|
||||
supported syntax, was already non-exhaustive, and stays accurate).
|
||||
- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name
|
||||
coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias
|
||||
describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib
|
||||
`instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console
|
||||
rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged
|
||||
(34 + 16, green); both packages `tsgo --noEmit` clean.
|
||||
|
||||
**Truncation layering - CodeMode truncation off in OpenCode** (user direction; resolves the
|
||||
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
|
||||
limits: absent = no truncation. All three limits are uniformly no-default - budgets are
|
||||
host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`;
|
||||
`boundOutput` only runs when the host set the limit. Explicit values validate as before
|
||||
(safe integer >= 0).
|
||||
- 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
|
||||
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
|
||||
`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
|
||||
is the richer one (file dump + explore/grep hint vs an inline marker).
|
||||
- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table
|
||||
and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB
|
||||
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
|
||||
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
|
||||
- `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
|
||||
host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`;
|
||||
`boundOutput` only runs when the host set the limit. Explicit values validate as before
|
||||
(safe integer >= 0).
|
||||
- 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
|
||||
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
|
||||
`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
|
||||
is the richer one (file dump + explore/grep hint vs an inline marker).
|
||||
- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table
|
||||
and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB
|
||||
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
|
||||
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
|
||||
|
||||
**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)
|
||||
@@ -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"
|
||||
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
|
||||
registry-resident tool service on the TaskTool precedent: `CodeModeTool =
|
||||
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
|
||||
`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
|
||||
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
|
||||
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
|
||||
tool count is consulted once (an Effect) before the synchronous filter, and code mode
|
||||
passes the predicate iff `flags.experimentalCodeMode` && count > 0.
|
||||
- **Description split on the `describeTask` precedent**: the tool's static base
|
||||
description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()`
|
||||
appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog,
|
||||
`catalogInstructions` in the adapter) at the same composition point as task - so
|
||||
`plugin.trigger("tool.definition")` sees the base description first.
|
||||
- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from
|
||||
`llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)`
|
||||
(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
|
||||
`resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters
|
||||
with the merged agent+session ruleset that `SessionTools.resolve` passes into the
|
||||
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
|
||||
(`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge
|
||||
`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.
|
||||
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.
|
||||
- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult`
|
||||
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
|
||||
through `Tool.Context` exactly like every registry tool).
|
||||
- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation,
|
||||
permission ruleset) was considered and deliberately skipped - the per-turn rebuild is
|
||||
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}
|
||||
.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct
|
||||
`define(...)` construction; description assertions target `catalogInstructions`, the
|
||||
registry's composition input) and gained permission coverage: deny excluded from
|
||||
catalog/search, ask-level stays visible and callable, denied tool undispatchable
|
||||
(unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/
|
||||
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
|
||||
through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green.
|
||||
- **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 =
|
||||
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
|
||||
`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
|
||||
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
|
||||
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
|
||||
tool count is consulted once (an Effect) before the synchronous filter, and code mode
|
||||
passes the predicate iff `flags.experimentalCodeMode` && count > 0.
|
||||
- **Description split on the `describeTask` precedent**: the tool's static base
|
||||
description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()`
|
||||
appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog,
|
||||
`catalogInstructions` in the adapter) at the same composition point as task - so
|
||||
`plugin.trigger("tool.definition")` sees the base description first.
|
||||
- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from
|
||||
`llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)`
|
||||
(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
|
||||
`resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters
|
||||
with the merged agent+session ruleset that `SessionTools.resolve` passes into the
|
||||
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
|
||||
(`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge
|
||||
`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.
|
||||
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.
|
||||
- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult`
|
||||
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
|
||||
through `Tool.Context` exactly like every registry tool).
|
||||
- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation,
|
||||
permission ruleset) was considered and deliberately skipped - the per-turn rebuild is
|
||||
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}
|
||||
.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct
|
||||
`define(...)` construction; description assertions target `catalogInstructions`, the
|
||||
registry's composition input) and gained permission coverage: deny excluded from
|
||||
catalog/search, ask-level stays visible and callable, denied tool undispatchable
|
||||
(unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/
|
||||
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
|
||||
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
|
||||
child calls" gap):
|
||||
|
||||
- `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 ->
|
||||
permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's
|
||||
`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) ->
|
||||
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
|
||||
`SessionTools.resolve` applies its existing model-facing shaping/truncation, code
|
||||
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,
|
||||
not about sessions or code mode.
|
||||
- **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
|
||||
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
|
||||
was needed.
|
||||
- **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
|
||||
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
|
||||
`toolCallId` (`options.toolCallId`) stays each caller's existing value
|
||||
(`ctx.callID ?? entry.key` for code mode).
|
||||
- **Child-scoped hook failures**: `CodeModeTool` (which now also yields
|
||||
`Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in
|
||||
`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
|
||||
calls in the same program keep running and interruption still propagates as
|
||||
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
|
||||
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
|
||||
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
|
||||
`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
|
||||
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
|
||||
45 + 16, session/tool/permission all green; this package untouched (211 pass).
|
||||
- `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 ->
|
||||
permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's
|
||||
`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) ->
|
||||
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
|
||||
`SessionTools.resolve` applies its existing model-facing shaping/truncation, code
|
||||
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,
|
||||
not about sessions or code mode.
|
||||
- **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
|
||||
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
|
||||
was needed.
|
||||
- **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
|
||||
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
|
||||
`toolCallId` (`options.toolCallId`) stays each caller's existing value
|
||||
(`ctx.callID ?? entry.key` for code mode).
|
||||
- **Child-scoped hook failures**: `CodeModeTool` (which now also yields
|
||||
`Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in
|
||||
`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
|
||||
calls in the same program keep running and interruption still propagates as
|
||||
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
|
||||
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
|
||||
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
|
||||
`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
|
||||
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
|
||||
45 + 16, session/tool/permission all green; this package untouched (211 pass).
|
||||
|
||||
**Signature rendering + compound-assignment parity fixes** (externally reported, both
|
||||
verified real with failing tests before fixing):
|
||||
|
||||
- **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`
|
||||
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
|
||||
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in 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
|
||||
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,
|
||||
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
|
||||
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
|
||||
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
|
||||
etc.). The collapse is now restricted to Effect's number-schema artifact
|
||||
(`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums),
|
||||
while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3.
|
||||
- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`):
|
||||
`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"`;
|
||||
`d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved
|
||||
verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`;
|
||||
compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and
|
||||
dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`)
|
||||
keep their separate short-circuit path (`evaluateLogicalAssignment`), and both
|
||||
assignment call sites still wrap results in `boundedData`. Deliberate side effect:
|
||||
compound assignment now rejects opaque references, consistent with binary operators.
|
||||
Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity,
|
||||
string `+=` object/array, member-target compound, 13-case operator sweep vs real JS).
|
||||
Package suite: 220 pass.
|
||||
- **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`
|
||||
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
|
||||
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in 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
|
||||
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,
|
||||
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
|
||||
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
|
||||
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
|
||||
etc.). The collapse is now restricted to Effect's number-schema artifact
|
||||
(`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums),
|
||||
while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3.
|
||||
- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`):
|
||||
`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"`;
|
||||
`d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved
|
||||
verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`;
|
||||
compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and
|
||||
dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`)
|
||||
keep their separate short-circuit path (`evaluateLogicalAssignment`), and both
|
||||
assignment call sites still wrap results in `boundedData`. Deliberate side effect:
|
||||
compound assignment now rejects opaque references, consistent with binary operators.
|
||||
Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity,
|
||||
string `+=` object/array, member-target compound, 13-case operator sweep vs real JS).
|
||||
Package suite: 220 pass.
|
||||
|
||||
---
|
||||
|
||||
## 4. Remaining work (detailed TODO)
|
||||
|
||||
### 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
|
||||
focused interpreter-surface pass rather than picked off piecemeal.
|
||||
|
||||
- [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 -
|
||||
`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.
|
||||
|
||||
### Next iteration: text-result handling (deliberate follow-up, user-directed)
|
||||
|
||||
- [ ] 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,
|
||||
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.
|
||||
|
||||
### Next iteration: stdlib surface (prioritized)
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
### Wiring-review findings (subagent code review of the OpenCode integration, triaged)
|
||||
|
||||
Pre-PR fixes (user-approved cut):
|
||||
|
||||
- [x] **Cancellation does not interrupt the interpreter** - the no-limits rationale claimed
|
||||
"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;
|
||||
@@ -1101,7 +1073,6 @@ Pre-PR fixes (user-approved cut):
|
||||
`title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`.
|
||||
|
||||
Post-MVP (logged, not blocking an experimental flag):
|
||||
|
||||
- [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
|
||||
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.
|
||||
|
||||
### Backlog / loose ends (non-blocking, any order)
|
||||
|
||||
- [ ] `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++`
|
||||
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
|
||||
|
||||
- **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
|
||||
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;
|
||||
|
||||
+348
-889
File diff suppressed because it is too large
Load Diff
@@ -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 type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
|
||||
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"
|
||||
|
||||
@@ -21,16 +21,11 @@ export type HostTools<R = never> = {
|
||||
|
||||
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
|
||||
? R
|
||||
: Tools extends {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
|
||||
}
|
||||
: Tools extends { readonly _tag: "CodeModeTool"; readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R> }
|
||||
? R
|
||||
: Tools extends object
|
||||
? string extends keyof Tools
|
||||
? never
|
||||
: Services<Tools[keyof Tools]>
|
||||
: never
|
||||
: Tools extends object
|
||||
? string extends keyof Tools ? never : Services<Tools[keyof Tools]>
|
||||
: never
|
||||
|
||||
/** Minimal audit record retained for each admitted tool call. */
|
||||
export type ToolCall = {
|
||||
@@ -73,13 +68,11 @@ export type SafeObject = Record<string, unknown>
|
||||
const reservedNamespace = "$codemode"
|
||||
const defaultMaxInlineCatalogTokens = 2_000
|
||||
const defaultSearchLimit = 10
|
||||
const searchSignature =
|
||||
"tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
|
||||
const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
|
||||
const toolExpression = (path: string) =>
|
||||
"tools" +
|
||||
path
|
||||
"tools" + path
|
||||
.split(".")
|
||||
.map((segment) => (identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`))
|
||||
.map((segment) => identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
|
||||
.join("")
|
||||
|
||||
export class ToolReference {
|
||||
@@ -95,12 +88,7 @@ const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
constructor(
|
||||
readonly kind:
|
||||
| "UnknownTool"
|
||||
| "InvalidToolInput"
|
||||
| "InvalidToolOutput"
|
||||
| "InvalidDataValue"
|
||||
| "ToolCallLimitExceeded",
|
||||
readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded",
|
||||
message: 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 =>
|
||||
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
|
||||
|
||||
const copyBounded = (
|
||||
value: unknown,
|
||||
label: string,
|
||||
depth: number,
|
||||
seen: Set<object>,
|
||||
preserveSandboxValues: boolean,
|
||||
): unknown => {
|
||||
const copyBounded = (value: unknown, label: string, depth: number, seen: Set<object>, preserveSandboxValues: boolean): unknown => {
|
||||
if (depth > 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
|
||||
// are never walked here (Map/Set members are validated where mutation happens, and the
|
||||
// real boundary still serializes them below).
|
||||
if (
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet
|
||||
) {
|
||||
if (value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet) {
|
||||
return value
|
||||
}
|
||||
// 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
|
||||
}
|
||||
if (
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet ||
|
||||
value instanceof RegExp ||
|
||||
value instanceof Map ||
|
||||
value instanceof Set
|
||||
value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet ||
|
||||
value instanceof RegExp || value instanceof Map || value instanceof Set
|
||||
) {
|
||||
return Object.create(null) as SafeObject
|
||||
}
|
||||
@@ -277,10 +250,7 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
||||
return value
|
||||
}
|
||||
|
||||
const definitions = <R>(
|
||||
tools: HostTools<R>,
|
||||
path: ReadonlyArray<string> = [],
|
||||
): Array<{ path: string; definition: Definition<R> }> => {
|
||||
const definitions = <R>(tools: HostTools<R>, path: ReadonlyArray<string> = []): Array<{ path: string; definition: Definition<R> }> => {
|
||||
const entries: Array<{ path: string; definition: Definition<R> }> = []
|
||||
for (const [name, value] of Object.entries(tools)) {
|
||||
const next = [...path, name]
|
||||
@@ -372,11 +342,8 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
|
||||
path,
|
||||
definition.description,
|
||||
...inputProperties(definition).flatMap(({ name, description: property }) =>
|
||||
property === undefined ? [name] : [name, property],
|
||||
),
|
||||
]
|
||||
.join("\n")
|
||||
.toLowerCase(),
|
||||
property === undefined ? [name] : [name, property]),
|
||||
].join("\n").toLowerCase(),
|
||||
})
|
||||
|
||||
/** The runtime search index over every described tool. Search is always registered. */
|
||||
@@ -429,8 +396,7 @@ export const discoveryPlan = <R>(
|
||||
namespace,
|
||||
picked: new Set<ToolDescription>(),
|
||||
queue: [...group].sort(
|
||||
(left, right) =>
|
||||
estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
|
||||
(left, right) => estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
|
||||
),
|
||||
}))
|
||||
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.",
|
||||
"- 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.",
|
||||
...(complete
|
||||
? []
|
||||
: ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
|
||||
...(complete ? [] : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
|
||||
]
|
||||
|
||||
const syntax = [
|
||||
@@ -536,21 +500,26 @@ export const discoveryPlan = <R>(
|
||||
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
|
||||
// Annotate only when a namespace is not fully shown, so a comprehensive
|
||||
// namespace reads cleanly and a truncated one is unambiguous.
|
||||
const label =
|
||||
picked.size === group.length
|
||||
? count
|
||||
: picked.size === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${picked.size} shown`
|
||||
const label = picked.size === group.length ? count : picked.size === 0 ? `${count}, none shown` : `${count}, ${picked.size} shown`
|
||||
toolSection.push(`- ${namespace} (${label})`)
|
||||
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
|
||||
}
|
||||
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 {
|
||||
catalog: described,
|
||||
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
|
||||
* discovery idioms, mirroring how calling an unknown tool fails.
|
||||
*/
|
||||
const namespaceKeys = <R>(
|
||||
tools: HostTools<R>,
|
||||
path: ReadonlyArray<string>,
|
||||
searchEnabled: boolean,
|
||||
): ReadonlyArray<string> => {
|
||||
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): ReadonlyArray<string> => {
|
||||
// The reserved discovery namespace is virtual (never present in the host tree); enumerate
|
||||
// it explicitly so `Object.keys(tools.$codemode)` matches the callable surface.
|
||||
if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"]
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
for (const segment of path) {
|
||||
if (
|
||||
isBlockedMember(segment) ||
|
||||
typeof value === "function" ||
|
||||
isDefinition(value) ||
|
||||
!Object.hasOwn(value, segment)
|
||||
) {
|
||||
if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
|
||||
throw new ToolRuntimeError(
|
||||
"UnknownTool",
|
||||
`Unknown tool namespace '${path.join(".")}'.`,
|
||||
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."],
|
||||
)
|
||||
}
|
||||
@@ -597,25 +555,12 @@ const namespaceKeys = <R>(
|
||||
return Object.keys(value)
|
||||
}
|
||||
|
||||
const resolve = <R>(
|
||||
tools: HostTools<R>,
|
||||
path: ReadonlyArray<string>,
|
||||
searchEnabled: boolean,
|
||||
): HostTool<R> | Definition<R> => {
|
||||
const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): HostTool<R> | Definition<R> => {
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
|
||||
for (const segment of path) {
|
||||
if (
|
||||
isBlockedMember(segment) ||
|
||||
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."] : [],
|
||||
)
|
||||
if (isBlockedMember(segment) || 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>
|
||||
}
|
||||
@@ -657,8 +602,7 @@ export const make = <R>(
|
||||
return effect.pipe(
|
||||
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
|
||||
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,
|
||||
keys: (path) => namespaceKeys(tools, path, searchEnabled),
|
||||
invoke: (path, args) =>
|
||||
Effect.gen(function* () {
|
||||
Effect.gen(function*() {
|
||||
const name = path.join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
|
||||
const call = { name }
|
||||
@@ -693,32 +637,17 @@ export const make = <R>(
|
||||
if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`)
|
||||
const input = externalArgs[0]
|
||||
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
"tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.",
|
||||
)
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.")
|
||||
}
|
||||
const request = input as { query?: unknown; namespace?: unknown; limit?: unknown }
|
||||
if (request.query !== undefined && typeof request.query !== "string") {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
"tools.$codemode.search query must be a string when provided.",
|
||||
)
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search query must be a string when provided.")
|
||||
}
|
||||
if (request.namespace !== undefined && typeof request.namespace !== "string") {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
"tools.$codemode.search namespace must be a string when provided.",
|
||||
)
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search namespace must be a string when provided.")
|
||||
}
|
||||
if (
|
||||
request.limit !== undefined &&
|
||||
(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.",
|
||||
)
|
||||
if (request.limit !== undefined && (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 namespace = typeof request.namespace === "string" ? request.namespace : undefined
|
||||
@@ -727,50 +656,40 @@ export const make = <R>(
|
||||
Effect.try({
|
||||
try: () => {
|
||||
const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit
|
||||
const scoped =
|
||||
namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
|
||||
const scoped = namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
|
||||
// A query that names one tool path exactly (canonical path or rendered
|
||||
// JavaScript expression) is a lookup, not a search: return that tool alone.
|
||||
const trimmed = query.trim()
|
||||
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
||||
const exact =
|
||||
pathQuery === ""
|
||||
? undefined
|
||||
: scoped.find(
|
||||
(entry) =>
|
||||
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
|
||||
)
|
||||
const exact = pathQuery === "" ? undefined : scoped.find((entry) =>
|
||||
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed)
|
||||
const terms = tokenize(query).map(termForms)
|
||||
// Additive field-weighted scoring, summed across terms: exact path or path
|
||||
// segment (20) > path substring (8) > description substring (4) > any
|
||||
// searchable text, incl. input parameter names/descriptions (2). Each term
|
||||
// matches a field when any of its forms (the term or a singular variant)
|
||||
// does. An empty query browses everything, alphabetical by path.
|
||||
const ranked =
|
||||
exact !== undefined
|
||||
? [exact]
|
||||
: scoped
|
||||
.map((entry) => {
|
||||
const path = entry.description.path.toLowerCase()
|
||||
const description = entry.description.description.toLowerCase()
|
||||
const score = terms.reduce(
|
||||
(total, forms) =>
|
||||
total +
|
||||
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
|
||||
(forms.some((form) => path.includes(form)) ? 8 : 0) +
|
||||
(forms.some((form) => description.includes(form)) ? 4 : 0) +
|
||||
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 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),
|
||||
const ranked = exact !== undefined
|
||||
? [exact]
|
||||
: scoped
|
||||
.map((entry) => {
|
||||
const path = entry.description.path.toLowerCase()
|
||||
const description = entry.description.description.toLowerCase()
|
||||
const score = terms.reduce(
|
||||
(total, forms) =>
|
||||
total +
|
||||
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
|
||||
(forms.some((form) => path.includes(form)) ? 8 : 0) +
|
||||
(forms.some((form) => description.includes(form)) ? 4 : 0) +
|
||||
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
|
||||
0,
|
||||
)
|
||||
.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
|
||||
// directly usable as the call site (`await tools.github.list({ ... })` or
|
||||
// `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty,
|
||||
@@ -792,12 +711,10 @@ export const make = <R>(
|
||||
const tool = resolve(tools, path, searchEnabled)
|
||||
let describedInput: unknown
|
||||
if (isDefinition(tool)) {
|
||||
if (externalArgs.length !== 1)
|
||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
describedInput = yield* Effect.try({
|
||||
try: () => decodeToolInput(tool, externalArgs[0]),
|
||||
catch: (cause) =>
|
||||
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
})
|
||||
}
|
||||
const input = isDefinition(tool) ? describedInput : externalArgs
|
||||
@@ -805,7 +722,7 @@ export const make = <R>(
|
||||
const currentCall = { index, name, input }
|
||||
if (isDefinition(tool)) {
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function* () {
|
||||
Effect.gen(function*() {
|
||||
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
|
||||
const result = yield* Effect.try({
|
||||
try: () => decodeToolOutput(tool, raw),
|
||||
@@ -817,7 +734,7 @@ export const make = <R>(
|
||||
)
|
||||
}
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function* () {
|
||||
Effect.gen(function*() {
|
||||
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
|
||||
}),
|
||||
currentCall,
|
||||
|
||||
@@ -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> =>
|
||||
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"
|
||||
|
||||
@@ -68,12 +69,10 @@ const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unkn
|
||||
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. */
|
||||
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) =>
|
||||
schema.type === "string" &&
|
||||
Array.isArray(schema.enum) &&
|
||||
schema.enum.length === 1 &&
|
||||
schema.type === "string" && Array.isArray(schema.enum) && schema.enum.length === 1 &&
|
||||
(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 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[lines.length - 1]!.trim() === "") lines.pop()
|
||||
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`
|
||||
}
|
||||
|
||||
const renderSchema = (
|
||||
schema: JsonSchema,
|
||||
ctx: RenderContext,
|
||||
depth = 0,
|
||||
seen: ReadonlySet<string> = new Set(),
|
||||
): string => {
|
||||
const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: ReadonlySet<string> = new Set()): string => {
|
||||
if (depth > MAX_RENDER_DEPTH) return "unknown"
|
||||
if (schema.$ref) {
|
||||
const name = schema.$ref.split("/").pop()
|
||||
@@ -153,16 +146,13 @@ const renderSchema = (
|
||||
if (
|
||||
alternatives.some((item) => item.type === "number") &&
|
||||
alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
|
||||
)
|
||||
return "number"
|
||||
) return "number"
|
||||
// An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
|
||||
// (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
|
||||
if (
|
||||
alternatives.length === 2 &&
|
||||
alternatives[0]?.type === "object" &&
|
||||
alternatives[0].properties === undefined &&
|
||||
alternatives[1]?.type === "array" &&
|
||||
alternatives[1].items === undefined
|
||||
alternatives[0]?.type === "object" && alternatives[0].properties === undefined &&
|
||||
alternatives[1]?.type === "array" && alternatives[1].items === undefined
|
||||
) {
|
||||
return "{}"
|
||||
}
|
||||
@@ -180,8 +170,7 @@ const renderSchema = (
|
||||
const required = new Set(schema.required ?? [])
|
||||
const properties = Object.entries(schema.properties ?? {})
|
||||
const additional = schema.additionalProperties
|
||||
const indexType =
|
||||
additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
|
||||
const indexType = additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined
|
||||
const field = ([name, value]: readonly [string, JsonSchema]) =>
|
||||
`${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.
|
||||
if (properties.length === 0 && indexType === undefined) return "{}"
|
||||
const pad = " ".repeat(depth + 1)
|
||||
const lines = properties.map(
|
||||
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`,
|
||||
)
|
||||
const lines = properties.map((entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`)
|
||||
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`)
|
||||
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.
|
||||
*/
|
||||
export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
|
||||
isEffectSchema(definition.input)
|
||||
? toTypeScript(definition.input, false, pretty)
|
||||
: jsonSchemaToTypeScript(definition.input, pretty)
|
||||
isEffectSchema(definition.input) ? toTypeScript(definition.input, false, pretty) : jsonSchemaToTypeScript(definition.input, pretty)
|
||||
|
||||
/**
|
||||
* The model-visible TypeScript type of a tool's result; tools without an output schema
|
||||
|
||||
@@ -28,7 +28,4 @@ export class SandboxSet {
|
||||
}
|
||||
|
||||
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
|
||||
value instanceof SandboxDate ||
|
||||
value instanceof SandboxRegExp ||
|
||||
value instanceof SandboxMap ||
|
||||
value instanceof SandboxSet
|
||||
value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import {
|
||||
CodeMode,
|
||||
ExecuteInputSchema,
|
||||
ExecuteResultSchema,
|
||||
Tool,
|
||||
toolError,
|
||||
type ExecutionLimits,
|
||||
} from "../src/index.js"
|
||||
import { CodeMode, ExecuteInputSchema, ExecuteResultSchema, Tool, toolError, type ExecutionLimits } from "../src/index.js"
|
||||
import type { Definition } from "../src/tool.js"
|
||||
|
||||
const run = (tool: Definition<never>) =>
|
||||
@@ -82,14 +75,11 @@ describe("CodeMode host failure boundary", () => {
|
||||
output: Schema.Unknown,
|
||||
run: () =>
|
||||
Effect.succeed(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
ownKeys: () => {
|
||||
throw new Error("host-output-secret")
|
||||
},
|
||||
new Proxy({}, {
|
||||
ownKeys: () => {
|
||||
throw new Error("host-output-secret")
|
||||
},
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
@@ -172,7 +162,9 @@ describe("CodeMode tool-call observation", () => {
|
||||
)
|
||||
|
||||
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 () => {
|
||||
@@ -181,26 +173,25 @@ describe("CodeMode tool-call observation", () => {
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: 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({
|
||||
tools: { context: { lookup } },
|
||||
onToolCallStart: (call) =>
|
||||
Effect.sync(() => {
|
||||
events.push({ phase: "start", index: call.index, name: call.name })
|
||||
}),
|
||||
onToolCallEnd: (call) =>
|
||||
Effect.sync(() => {
|
||||
expect(call.durationMs).toBeGreaterThanOrEqual(0)
|
||||
events.push({
|
||||
phase: "end",
|
||||
index: call.index,
|
||||
name: call.name,
|
||||
outcome: call.outcome,
|
||||
...(call.message === undefined ? {} : { message: call.message }),
|
||||
})
|
||||
}),
|
||||
onToolCallStart: (call) => Effect.sync(() => {
|
||||
events.push({ phase: "start", index: call.index, name: call.name })
|
||||
}),
|
||||
onToolCallEnd: (call) => Effect.sync(() => {
|
||||
expect(call.durationMs).toBeGreaterThanOrEqual(0)
|
||||
events.push({
|
||||
phase: "end",
|
||||
index: call.index,
|
||||
name: call.name,
|
||||
outcome: call.outcome,
|
||||
...(call.message === undefined ? {} : { message: call.message }),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
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", () => {
|
||||
test("captures console output as bounded result logs", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const returned = console.log("Thread info:", { name: "Demo", count: 2 })
|
||||
console.warn("careful")
|
||||
return returned
|
||||
`,
|
||||
}),
|
||||
)
|
||||
}))
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
ok: true,
|
||||
@@ -239,45 +228,39 @@ describe("CodeMode console capture", () => {
|
||||
})
|
||||
|
||||
test("keeps logs captured before failures", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
console.log("before failure")
|
||||
throw new Error("boom")
|
||||
`,
|
||||
}),
|
||||
)
|
||||
}))
|
||||
|
||||
expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"])
|
||||
expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom")
|
||||
})
|
||||
|
||||
test("prints NaN and Infinity literally instead of the JSON null", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
console.log(NaN)
|
||||
console.log(Infinity, -Infinity)
|
||||
console.log({ ratio: NaN, bounds: [Infinity] })
|
||||
return null
|
||||
`,
|
||||
}),
|
||||
)
|
||||
}))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}'])
|
||||
})
|
||||
|
||||
test("renders sandbox values nested inside logged containers", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) })
|
||||
console.log([new Date(0)])
|
||||
return null
|
||||
`,
|
||||
}),
|
||||
)
|
||||
}))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
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 () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
const m = new Map()
|
||||
m.set("self", m)
|
||||
console.log({ box: m })
|
||||
console.log({ fn: (x) => x, ok: 1 })
|
||||
return null
|
||||
`,
|
||||
}),
|
||||
)
|
||||
}))
|
||||
|
||||
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 () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
console.table([{ when: new Date(0), n: NaN }])
|
||||
return null
|
||||
`,
|
||||
}),
|
||||
)
|
||||
}))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"])
|
||||
})
|
||||
|
||||
test("captures console.dir and console.table output", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
console.dir({ nested: { ok: true } })
|
||||
console.table([
|
||||
{ name: "Kit", count: 1, hidden: "x" },
|
||||
@@ -328,13 +309,15 @@ describe("CodeMode console capture", () => {
|
||||
], ["name", "count"])
|
||||
return "done"
|
||||
`,
|
||||
}),
|
||||
)
|
||||
}))
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
ok: true,
|
||||
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: [],
|
||||
})
|
||||
})
|
||||
@@ -342,11 +325,9 @@ describe("CodeMode console capture", () => {
|
||||
|
||||
describe("CodeMode output budget", () => {
|
||||
test("absent maxOutputBytes means no truncation at all", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`,
|
||||
}),
|
||||
)
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`,
|
||||
}))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
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 () => {
|
||||
const limits: ExecutionLimits = { maxOutputBytes: 40 }
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `return { data: "${"x".repeat(200)}" }`,
|
||||
limits,
|
||||
}),
|
||||
)
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `return { data: "${"x".repeat(200)}" }`,
|
||||
limits,
|
||||
}))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(typeof result.value).toBe("string")
|
||||
expect(result.value).toMatch(
|
||||
/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/,
|
||||
)
|
||||
expect(result.value).toMatch(/^\{"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)
|
||||
})
|
||||
|
||||
test("keeps leading logs within the remaining budget and marks the cut", async () => {
|
||||
const limits: ExecutionLimits = { maxOutputBytes: 40 }
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
console.log("first line")
|
||||
console.log("${"y".repeat(200)}")
|
||||
return "ok"
|
||||
`,
|
||||
limits,
|
||||
}),
|
||||
)
|
||||
limits,
|
||||
}))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
@@ -395,14 +370,12 @@ describe("CodeMode output budget", () => {
|
||||
})
|
||||
|
||||
test("does not mark results within the budget", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `
|
||||
console.log("fits")
|
||||
return { fits: true }
|
||||
`,
|
||||
}),
|
||||
)
|
||||
}))
|
||||
expect(result).toStrictEqual({
|
||||
ok: true,
|
||||
value: { fits: true },
|
||||
@@ -422,21 +395,18 @@ describe("CodeMode schema flexibility", () => {
|
||||
properties: { id: { type: "string" }, count: { type: "number" } },
|
||||
required: ["id"],
|
||||
},
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return { echoed: input }
|
||||
}),
|
||||
run: (input) => Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return { echoed: input }
|
||||
}),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { adapter: { call } } })
|
||||
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "adapter.call",
|
||||
description: "Call an adapter-described tool",
|
||||
signature: "tools.adapter.call(input: { id: string; count?: number }): Promise<unknown>",
|
||||
},
|
||||
])
|
||||
expect(runtime.catalog()).toStrictEqual([{
|
||||
path: "adapter.call",
|
||||
description: "Call an adapter-described tool",
|
||||
signature: "tools.adapter.call(input: { id: string; count?: number }): Promise<unknown>",
|
||||
}])
|
||||
|
||||
// JSON Schema is render-only: mistyped input passes through unvalidated.
|
||||
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"] },
|
||||
output: {
|
||||
$ref: "#/$defs/User",
|
||||
$defs: {
|
||||
User: {
|
||||
type: "object",
|
||||
properties: { login: { type: "string" }, id: { type: "number" } },
|
||||
required: ["login", "id"],
|
||||
},
|
||||
},
|
||||
$defs: { User: { type: "object", properties: { login: { type: "string" }, id: { type: "number" } }, required: ["login", "id"] } },
|
||||
},
|
||||
run: () => Effect.succeed({ login: "kit", id: 7 }),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { users: { lookup } } })
|
||||
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "users.lookup",
|
||||
description: "Look up a user",
|
||||
signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>",
|
||||
},
|
||||
])
|
||||
expect(runtime.catalog()).toStrictEqual([{
|
||||
path: "users.lookup",
|
||||
description: "Look up a user",
|
||||
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" })`))
|
||||
expect(result.ok).toBe(true)
|
||||
@@ -516,20 +478,16 @@ describe("CodeMode public contract", () => {
|
||||
expect(agentTool.input).toBe(ExecuteInputSchema)
|
||||
expect(agentTool.output).toBe(ExecuteResultSchema)
|
||||
expect(agentTool.description).toBe(runtime.instructions())
|
||||
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(
|
||||
projected,
|
||||
)
|
||||
expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(projected)
|
||||
})
|
||||
|
||||
test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>",
|
||||
},
|
||||
])
|
||||
expect(runtime.catalog()).toStrictEqual([{
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>",
|
||||
}])
|
||||
expect(runtime.instructions()).toContain("Available tools (COMPLETE list")
|
||||
expect(runtime.instructions()).toContain("- orders (1 tool)")
|
||||
expect(runtime.instructions()).toContain(
|
||||
@@ -544,13 +502,11 @@ describe("CodeMode public contract", () => {
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value).toStrictEqual({
|
||||
items: [
|
||||
{
|
||||
path: "tools.orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>",
|
||||
},
|
||||
],
|
||||
items: [{
|
||||
path: "tools.orders.lookup",
|
||||
description: "Look up an order by ID",
|
||||
signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>",
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
}
|
||||
@@ -565,43 +521,31 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
|
||||
|
||||
expect(runtime.catalog()).toStrictEqual([
|
||||
{
|
||||
path: "context7.resolve-library-id",
|
||||
description: "Resolve a library ID",
|
||||
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.catalog()).toStrictEqual([{
|
||||
path: "context7.resolve-library-id",
|
||||
description: "Resolve a library ID",
|
||||
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>')
|
||||
|
||||
const search = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`),
|
||||
)
|
||||
const search = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`))
|
||||
expect(search.ok).toBe(true)
|
||||
if (search.ok) {
|
||||
expect(search.value).toStrictEqual({
|
||||
items: [
|
||||
{
|
||||
path: 'tools.context7["resolve-library-id"]',
|
||||
description: "Resolve a library ID",
|
||||
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise<string>',
|
||||
},
|
||||
],
|
||||
items: [{
|
||||
path: 'tools.context7["resolve-library-id"]',
|
||||
description: "Resolve a library ID",
|
||||
signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise<string>',
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
}
|
||||
|
||||
const call = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`),
|
||||
)
|
||||
const call = await Effect.runPromise(runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`))
|
||||
expect(call.ok).toBe(true)
|
||||
if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
|
||||
|
||||
const exact = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`),
|
||||
)
|
||||
const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`))
|
||||
expect(exact.ok).toBe(true)
|
||||
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("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list"))
|
||||
// The workflow carries the result-shape guidance; Rules only add content beyond it.
|
||||
expect(instructions).toContain(
|
||||
'`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
|
||||
)
|
||||
expect(instructions).toContain('`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("raw payloads get truncated and waste context")
|
||||
expect(instructions).toContain("`const res = await tools.<namespace>.<tool>(input)`")
|
||||
@@ -642,12 +584,8 @@ describe("CodeMode public contract", () => {
|
||||
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.',
|
||||
)
|
||||
expect(partial).toContain(
|
||||
"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("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).not.toContain("total_count")
|
||||
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("splice")
|
||||
// The data-boundary note survives.
|
||||
expect(instructions).toContain(
|
||||
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
|
||||
)
|
||||
expect(instructions).toContain("Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.")
|
||||
})
|
||||
|
||||
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 } },
|
||||
discovery: { maxInlineCatalogTokens: 0 },
|
||||
})
|
||||
expect(runtime.instructions()).toContain(
|
||||
"Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)",
|
||||
)
|
||||
expect(runtime.instructions()).toContain("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("- orders (1 tool, none shown)")
|
||||
expect(runtime.instructions()).toMatch(/\$codemode\.search/)
|
||||
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
const result = await Effect.runPromise(runtime.execute(`
|
||||
return await tools.$codemode.search({
|
||||
query: "send message attachment upload file to current Discord thread",
|
||||
limit: 2
|
||||
})
|
||||
`),
|
||||
)
|
||||
`))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toStrictEqual({
|
||||
@@ -734,27 +666,19 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }])
|
||||
|
||||
const variants = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
const variants = await Effect.runPromise(runtime.execute(`
|
||||
return await Promise.all([
|
||||
tools.$codemode.search({ query: "file" }),
|
||||
tools.$codemode.search({ query: "image" })
|
||||
])
|
||||
`),
|
||||
)
|
||||
`))
|
||||
expect(variants.ok).toBe(true)
|
||||
if (variants.ok) {
|
||||
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe(
|
||||
"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 }> }>)[0]?.items[0]?.path).toBe("tools.thread.uploadFile")
|
||||
expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe("tools.thread.generateImage")
|
||||
}
|
||||
|
||||
const removed = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`),
|
||||
)
|
||||
const removed = await Effect.runPromise(runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`))
|
||||
expect(removed.ok).toBe(false)
|
||||
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"]) {
|
||||
const exact = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
|
||||
)
|
||||
const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`))
|
||||
expect(exact.ok).toBe(true)
|
||||
if (exact.ok) {
|
||||
expect(exact.value).toStrictEqual({
|
||||
items: [
|
||||
{
|
||||
path: "tools.many.tool13",
|
||||
description: "Numbered tool 13",
|
||||
signature: "tools.many.tool13(input: {\n id: string\n}): Promise<string>",
|
||||
},
|
||||
],
|
||||
items: [{
|
||||
path: "tools.many.tool13",
|
||||
description: "Numbered tool 13",
|
||||
signature: "tools.many.tool13(input: {\n id: string\n}): Promise<string>",
|
||||
}],
|
||||
total: 1,
|
||||
})
|
||||
}
|
||||
@@ -817,23 +737,20 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
// Empty query + namespace browses just that namespace, alphabetical by path.
|
||||
const browse = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`),
|
||||
)
|
||||
const browse = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "", namespace: "github" })`,
|
||||
))
|
||||
expect(browse.ok).toBe(true)
|
||||
if (browse.ok) {
|
||||
const value = browse.value as { items: Array<{ path: string }>; total: number }
|
||||
expect(value.total).toBe(2)
|
||||
expect(value.items.map((item) => item.path)).toStrictEqual([
|
||||
"tools.github.create_issue",
|
||||
"tools.github.list_issues",
|
||||
])
|
||||
expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.create_issue", "tools.github.list_issues"])
|
||||
}
|
||||
|
||||
// A query + namespace ranks within that namespace only.
|
||||
const scoped = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`),
|
||||
)
|
||||
const scoped = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`,
|
||||
))
|
||||
expect(scoped.ok).toBe(true)
|
||||
if (scoped.ok) {
|
||||
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")
|
||||
}
|
||||
|
||||
const invalid = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`),
|
||||
)
|
||||
const invalid = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "issues", namespace: 7 })`,
|
||||
))
|
||||
expect(invalid.ok).toBe(false)
|
||||
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
|
||||
// property names, which the searchable text includes.
|
||||
const byParameter = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`),
|
||||
)
|
||||
const byParameter = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "attachment" })`,
|
||||
))
|
||||
expect(byParameter.ok).toBe(true)
|
||||
if (byParameter.ok) {
|
||||
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.
|
||||
const bySubstring = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "docum" })`),
|
||||
)
|
||||
const bySubstring = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "docum" })`,
|
||||
))
|
||||
expect(bySubstring.ok).toBe(true)
|
||||
if (bySubstring.ok) {
|
||||
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)...
|
||||
const plural = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`),
|
||||
)
|
||||
const plural = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`,
|
||||
))
|
||||
expect(plural.ok).toBe(true)
|
||||
if (plural.ok) {
|
||||
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.
|
||||
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)
|
||||
if (ranked.ok) {
|
||||
const value = ranked.value as { items: Array<{ path: string }>; total: number }
|
||||
expect(value.total).toBe(2)
|
||||
expect(value.items.map((item) => item.path)).toStrictEqual([
|
||||
"tools.github.list_issues",
|
||||
"tools.tracker.fetch_all",
|
||||
])
|
||||
expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.list_issues", "tools.tracker.fetch_all"])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -966,12 +882,8 @@ describe("CodeMode public contract", () => {
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
const expensive = Tool.make({
|
||||
description:
|
||||
"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,
|
||||
}),
|
||||
description: "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 }),
|
||||
output: Schema.String,
|
||||
run: () => Effect.succeed("ok"),
|
||||
})
|
||||
@@ -984,9 +896,7 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
const instructions = runtime.instructions()
|
||||
expect(instructions).toContain(
|
||||
"Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)",
|
||||
)
|
||||
expect(instructions).toContain("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(" - tools.alpha.cheap(input: { q: string }): Promise<string> // Cheap")
|
||||
expect(instructions).not.toContain("tools.alpha.expensive(")
|
||||
@@ -1002,11 +912,10 @@ describe("CodeMode public contract", () => {
|
||||
description: "Double a number",
|
||||
input: Schema.Struct({ value: Schema.NumberFromString }),
|
||||
output: Schema.NumberFromString,
|
||||
run: ({ value }) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(value)
|
||||
return String(value * 2)
|
||||
}),
|
||||
run: ({ value }) => Effect.sync(() => {
|
||||
observed.push(value)
|
||||
return String(value * 2)
|
||||
}),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { math: { double: transformed } },
|
||||
@@ -1025,11 +934,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
test("returns JSON-safe data and normalizes undefined to null", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
code: `return { top: undefined, nested: [1, undefined] }`,
|
||||
}),
|
||||
)
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
code: `return { top: undefined, nested: [1, undefined] }`,
|
||||
}))
|
||||
expect(result).toStrictEqual({
|
||||
ok: true,
|
||||
value: { top: null, nested: [1, null] },
|
||||
@@ -1040,20 +947,18 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
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: Number.POSITIVE_INFINITY } })).toThrow(
|
||||
RangeError,
|
||||
)
|
||||
expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).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.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools,
|
||||
discovery: { maxInlineCatalogTokens: 0 },
|
||||
}).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`),
|
||||
)
|
||||
const result = await Effect.runPromise(CodeMode.make({
|
||||
tools,
|
||||
discovery: { maxInlineCatalogTokens: 0 },
|
||||
}).execute(
|
||||
`return await tools.$codemode.search({ query: "order", limit: 0.5 })`,
|
||||
))
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.error.kind).toBe("InvalidToolInput")
|
||||
@@ -1074,16 +979,14 @@ describe("CodeMode public contract", () => {
|
||||
output: Schema.Number,
|
||||
run: () => Effect.succeed(1),
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { count: counter } },
|
||||
code: `
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
tools: { host: { count: counter } },
|
||||
code: `
|
||||
let total = 0
|
||||
for (let i = 0; i < 150; i += 1) total += await tools.host.count({})
|
||||
return total
|
||||
`,
|
||||
}),
|
||||
)
|
||||
}))
|
||||
expect(result).toMatchObject({ ok: true, value: 150 })
|
||||
if (result.ok) expect(result.toolCalls.length).toBe(150)
|
||||
})
|
||||
@@ -1105,6 +1008,8 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
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/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,12 +36,10 @@ const error = async (code: string) => {
|
||||
|
||||
describe("Object.keys over tool references", () => {
|
||||
test("enumerates top-level namespaces (the transcript program)", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const namespaces = Object.keys(tools)
|
||||
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 () => {
|
||||
@@ -94,8 +92,7 @@ describe("Object.keys over arrays", () => {
|
||||
|
||||
describe("for...in", () => {
|
||||
test("iterates own enumerable keys of a plain object with break/continue", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const seen = []
|
||||
for (const key in { a: 1, b: 2, c: 3, d: 4 }) {
|
||||
if (key === "b") continue
|
||||
@@ -103,50 +100,41 @@ describe("for...in", () => {
|
||||
seen.push(key)
|
||||
}
|
||||
return seen
|
||||
`),
|
||||
).toEqual(["a", "c"])
|
||||
`)).toEqual(["a", "c"])
|
||||
})
|
||||
|
||||
test("iterates index strings over arrays", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const indexes = []
|
||||
for (const i in ["x", "y", "z"]) {
|
||||
if (i === "2") break
|
||||
indexes.push(i)
|
||||
}
|
||||
return indexes
|
||||
`),
|
||||
).toEqual(["0", "1"])
|
||||
`)).toEqual(["0", "1"])
|
||||
})
|
||||
|
||||
test("supports let declarations and bare identifiers", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
let last = ""
|
||||
for (let key in { a: 1, b: 2 }) last = key
|
||||
return last
|
||||
`),
|
||||
).toBe("b")
|
||||
expect(
|
||||
await value(`
|
||||
`)).toBe("b")
|
||||
expect(await value(`
|
||||
let key = "before"
|
||||
for (key in { only: 1 }) {}
|
||||
return key
|
||||
`),
|
||||
).toBe("only")
|
||||
`)).toBe("only")
|
||||
})
|
||||
|
||||
test("enumerates namespaces and tools from the host tool tree", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const names = []
|
||||
for (const ns in tools) {
|
||||
for (const name in tools[ns]) names.push(ns + "." + name)
|
||||
}
|
||||
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 () => {
|
||||
|
||||
@@ -143,40 +143,21 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
|
||||
|
||||
describe("Error values and instanceof", () => {
|
||||
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([
|
||||
true,
|
||||
"Error",
|
||||
"boom",
|
||||
])
|
||||
expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "boom"])
|
||||
})
|
||||
|
||||
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([
|
||||
true,
|
||||
"Error",
|
||||
"plain",
|
||||
])
|
||||
expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual([
|
||||
"Error",
|
||||
"",
|
||||
true,
|
||||
])
|
||||
expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([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 () => {
|
||||
expect(
|
||||
await value(
|
||||
`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`,
|
||||
),
|
||||
).toEqual([true, true, false])
|
||||
expect(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)
|
||||
})
|
||||
|
||||
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([
|
||||
true,
|
||||
"x",
|
||||
])
|
||||
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([true, "x"])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
// JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the
|
||||
// message keeps the engine's position detail.
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
try { JSON.parse("{oops") } catch (e) {
|
||||
return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")]
|
||||
}
|
||||
`),
|
||||
).toEqual(["SyntaxError", true, true, false, true])
|
||||
expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)).toEqual([
|
||||
"ReferenceError",
|
||||
true,
|
||||
])
|
||||
expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)).toEqual([
|
||||
"TypeError",
|
||||
true,
|
||||
])
|
||||
expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)).toEqual(
|
||||
["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,
|
||||
])
|
||||
`)).toEqual(["SyntaxError", true, true, false, true])
|
||||
expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`))
|
||||
.toEqual(["ReferenceError", true])
|
||||
expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`))
|
||||
.toEqual(["TypeError", true])
|
||||
expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`))
|
||||
.toEqual(["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 () => {
|
||||
expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)).toEqual([
|
||||
"Error",
|
||||
true,
|
||||
])
|
||||
expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`))
|
||||
.toEqual(["Error", true])
|
||||
})
|
||||
|
||||
test("Promise.allSettled rejection reasons are Error values", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const settled = await Promise.allSettled([Promise.reject(new Error("b"))])
|
||||
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 () => {
|
||||
@@ -237,11 +203,7 @@ describe("Error values and instanceof", () => {
|
||||
})
|
||||
|
||||
test("plain data is never instanceof Error", async () => {
|
||||
expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
])
|
||||
expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([false, false, false])
|
||||
})
|
||||
|
||||
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", () => {
|
||||
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({
|
||||
removed: [2, 3],
|
||||
a: [1, 4],
|
||||
})
|
||||
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] })
|
||||
})
|
||||
|
||||
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 = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({
|
||||
removed: [2],
|
||||
a: [1, "x", 3],
|
||||
})
|
||||
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] })
|
||||
})
|
||||
|
||||
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({
|
||||
removed: [2, 3],
|
||||
a: [1],
|
||||
})
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({
|
||||
removed: [3],
|
||||
a: [1, 2],
|
||||
})
|
||||
expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ removed: [2, 3], 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 () => {
|
||||
@@ -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 () => {
|
||||
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(`
|
||||
expect(await value(`
|
||||
const out = []
|
||||
for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item)
|
||||
return out
|
||||
`),
|
||||
).toEqual(["0:a", "1:b"])
|
||||
`)).toEqual(["0:a", "1:b"])
|
||||
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 () => {
|
||||
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")
|
||||
})
|
||||
|
||||
test("sandbox Date numeric compound ops use its time value", async () => {
|
||||
expect(
|
||||
await pair(`let d = new Date(1000); d -= 400; return d`, `let d = new Date(1000); d = d - 400; return d`),
|
||||
).toBe(600)
|
||||
expect(await pair(`let d = new Date(1000); d /= 4; return d`, `let d = new Date(1000); d = d / 4; return d`)).toBe(
|
||||
250,
|
||||
)
|
||||
expect(await pair(
|
||||
`let d = new Date(1000); d -= 400; return d`,
|
||||
`let d = new Date(1000); d = d - 400; return d`,
|
||||
)).toBe(600)
|
||||
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 () => {
|
||||
expect(await pair(`let x = "a"; x += { b: 1 }; return x`, `let x = "a"; x = x + { b: 1 }; return x`)).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")
|
||||
expect(await pair(
|
||||
`let x = "a"; x += { b: 1 }; return x`,
|
||||
`let x = "a"; x = x + { b: 1 }; return x`,
|
||||
)).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 () => {
|
||||
expect(
|
||||
await pair(
|
||||
`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`,
|
||||
),
|
||||
).toBe("t1970-01-01T00:00:00.000Z")
|
||||
expect(await pair(
|
||||
`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`,
|
||||
)).toBe("t1970-01-01T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("numeric and string compound operators sweep identically to their expansions", async () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ const sleepyTool = (trace: Trace) =>
|
||||
input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }),
|
||||
output: Schema.Number,
|
||||
run: ({ id, ms }) =>
|
||||
Effect.gen(function* () {
|
||||
Effect.gen(function*() {
|
||||
trace.starts.push(id)
|
||||
trace.active += 1
|
||||
trace.maxActive = Math.max(trace.maxActive, trace.active)
|
||||
@@ -31,14 +31,10 @@ const sleepyTool = (trace: Trace) =>
|
||||
trace.active -= 1
|
||||
trace.completed += 1
|
||||
return id
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.sync(() => {
|
||||
trace.active -= 1
|
||||
trace.interrupted += 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
}).pipe(Effect.onInterrupt(() => Effect.sync(() => {
|
||||
trace.active -= 1
|
||||
trace.interrupted += 1
|
||||
}))),
|
||||
})
|
||||
|
||||
const failingTool = Tool.make({
|
||||
@@ -50,13 +46,11 @@ const failingTool = Tool.make({
|
||||
|
||||
const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise<ExecuteResult> => {
|
||||
const trace = options.trace ?? makeTrace()
|
||||
return Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
|
||||
code,
|
||||
...(options.limits ? { limits: options.limits } : {}),
|
||||
}),
|
||||
)
|
||||
return Effect.runPromise(CodeMode.execute({
|
||||
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } },
|
||||
code,
|
||||
...(options.limits ? { limits: options.limits } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
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 () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const p = tools.host.fail({})
|
||||
try {
|
||||
await p
|
||||
@@ -136,8 +129,7 @@ describe("first-class promise values", () => {
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
`)).toBe("Lookup refused")
|
||||
})
|
||||
|
||||
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", () => {
|
||||
test("mixes promises and plain values, preserving order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
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 () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const calls = []
|
||||
calls.push(tools.host.sleepy({ id: 1 }))
|
||||
calls.push(7)
|
||||
const more = [tools.host.sleepy({ id: 2 })]
|
||||
const batch = [...calls, ...more, "x"]
|
||||
return await Promise.all(batch)
|
||||
`),
|
||||
).toEqual([1, 7, 2, "x"])
|
||||
`)).toEqual([1, 7, 2, "x"])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
try {
|
||||
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
`)).toBe("Lookup refused")
|
||||
})
|
||||
|
||||
test("a non-collection argument is a clear error", async () => {
|
||||
@@ -278,16 +264,14 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
|
||||
describe("Promise.allSettled", () => {
|
||||
test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
return await Promise.allSettled([
|
||||
tools.host.sleepy({ id: 5 }),
|
||||
tools.host.fail({}),
|
||||
"plain",
|
||||
Promise.reject(new Error("boom")),
|
||||
])
|
||||
`),
|
||||
).toEqual([
|
||||
`)).toEqual([
|
||||
{ status: "fulfilled", value: 5 },
|
||||
{ status: "rejected", reason: { name: "Error", message: "Lookup refused" } },
|
||||
{ status: "fulfilled", value: "plain" },
|
||||
@@ -322,8 +306,7 @@ describe("Promise.race", () => {
|
||||
})
|
||||
|
||||
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
||||
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
|
||||
const winner = await Promise.race([fast, slow])
|
||||
@@ -333,31 +316,23 @@ describe("Promise.race", () => {
|
||||
} catch (e) {
|
||||
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 () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
try {
|
||||
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
`)).toBe("Lookup refused")
|
||||
})
|
||||
|
||||
test("a plain value wins over pending promises", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(
|
||||
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
|
||||
).toBe("immediate")
|
||||
expect(await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace })).toBe("immediate")
|
||||
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 () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
try {
|
||||
await Promise.reject("nope")
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e
|
||||
}
|
||||
`),
|
||||
).toBe("nope")
|
||||
`)).toBe("nope")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -83,9 +83,15 @@ describe("pretty signature rendering", () => {
|
||||
true,
|
||||
)
|
||||
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(" /** @format uri */\n homepage?: string")
|
||||
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({
|
||||
description: "Adapter tool with awkward field names",
|
||||
input: rawSchema,
|
||||
output: {
|
||||
type: "object",
|
||||
properties: { "content-type": { type: "string" } },
|
||||
required: ["content-type"],
|
||||
} as const,
|
||||
output: { type: "object", properties: { "content-type": { type: "string" } }, required: ["content-type"] } as const,
|
||||
run: () => Effect.succeed({ "content-type": "text/plain" }),
|
||||
})
|
||||
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 search = async (query: string) => {
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
|
||||
)
|
||||
const result = await Effect.runPromise(runtime.execute(
|
||||
`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`,
|
||||
))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
return result.value as { items: Array<{ path: string; signature: string }>; total: number }
|
||||
|
||||
@@ -40,11 +40,7 @@ describe("Date", () => {
|
||||
})
|
||||
|
||||
test("UTC getters read calendar components", async () => {
|
||||
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])
|
||||
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])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe(
|
||||
"caught",
|
||||
)
|
||||
expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe("caught")
|
||||
})
|
||||
|
||||
test("template interpolation renders the ISO form", async () => {
|
||||
@@ -78,18 +72,14 @@ describe("Date", () => {
|
||||
})
|
||||
|
||||
test("sorting dates with a numeric comparator", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const dates = [new Date(3000), new Date(1000), new Date(2000)]
|
||||
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 () => {
|
||||
expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([
|
||||
2024, 0, 2,
|
||||
])
|
||||
expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([2024, 0, 2])
|
||||
})
|
||||
|
||||
test("typeof and unknown properties are forgiving", async () => {
|
||||
@@ -105,31 +95,25 @@ describe("RegExp", () => {
|
||||
})
|
||||
|
||||
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(
|
||||
{
|
||||
full: "abb",
|
||||
group: "bb",
|
||||
index: 2,
|
||||
},
|
||||
)
|
||||
expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual({
|
||||
full: "abb",
|
||||
group: "bb",
|
||||
index: 2,
|
||||
})
|
||||
expect(await value(`return /a/.exec("zzz")`)).toBeNull()
|
||||
})
|
||||
|
||||
test("named groups read through", async () => {
|
||||
expect(
|
||||
await value(`const m = /(?<word>[a-z]+)-(?<num>\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`),
|
||||
).toBe("ab42")
|
||||
expect(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 () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const r = /\\d+/g
|
||||
const first = r.exec("a1b22c")
|
||||
const second = r.exec("a1b22c")
|
||||
return [first[0], second[0]]
|
||||
`),
|
||||
).toEqual(["1", "22"])
|
||||
`)).toEqual(["1", "22"])
|
||||
})
|
||||
|
||||
test("string match: non-global carries index, global lists all matches", async () => {
|
||||
@@ -210,51 +194,34 @@ describe("RegExp", () => {
|
||||
|
||||
describe("Map", () => {
|
||||
test("get/set/has/size with chaining", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const m = new Map()
|
||||
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 }
|
||||
`),
|
||||
).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 () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const key = { id: 1 }
|
||||
const m = new Map()
|
||||
m.set(key, "hit")
|
||||
return [m.get(key), m.get({ id: 1 }) === undefined]
|
||||
`),
|
||||
).toEqual(["hit", true])
|
||||
`)).toEqual(["hit", true])
|
||||
})
|
||||
|
||||
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]]); 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 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(["flat"])`)).message).toMatch(/\[key, value\] pairs/)
|
||||
})
|
||||
|
||||
test("keys/values/entries return arrays", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
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 () => {
|
||||
@@ -263,15 +230,13 @@ describe("Map", () => {
|
||||
})
|
||||
|
||||
test("for...of iterates [key, value] pairs with destructuring", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
let total = 0
|
||||
let names = ""
|
||||
for (const [key, count] of m) { names += key; total += count }
|
||||
return names + total
|
||||
`),
|
||||
).toBe("ab3")
|
||||
`)).toBe("ab3")
|
||||
})
|
||||
|
||||
test("spread produces entry pairs", async () => {
|
||||
@@ -279,38 +244,32 @@ describe("Map", () => {
|
||||
})
|
||||
|
||||
test("forEach passes (value, key)", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
const seen = []
|
||||
m.forEach((count, key) => seen.push(key + count))
|
||||
return seen
|
||||
`),
|
||||
).toEqual(["a1", "b2"])
|
||||
`)).toEqual(["a1", "b2"])
|
||||
})
|
||||
|
||||
test("delete and clear", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const m = new Map([["a", 1], ["b", 2]])
|
||||
const removed = m.delete("a")
|
||||
const missed = m.delete("zz")
|
||||
const sizeAfterDelete = m.size
|
||||
m.clear()
|
||||
return [removed, missed, sizeAfterDelete, m.size]
|
||||
`),
|
||||
).toEqual([true, false, 1, 0])
|
||||
`)).toEqual([true, false, 1, 0])
|
||||
})
|
||||
|
||||
test("counting idiom: grouped tallies", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const words = ["a", "b", "a", "c", "a"]
|
||||
const counts = new Map()
|
||||
for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1)
|
||||
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 () => {
|
||||
@@ -327,14 +286,12 @@ describe("Map", () => {
|
||||
|
||||
describe("Set", () => {
|
||||
test("add/has/delete/size with chaining", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const s = new Set()
|
||||
s.add(1).add(2).add(1)
|
||||
const removed = s.delete(2)
|
||||
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 () => {
|
||||
@@ -351,13 +308,11 @@ describe("Set", () => {
|
||||
})
|
||||
|
||||
test("for...of iterates values", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
let total = 0
|
||||
for (const n of new Set([1, 2, 3])) total += n
|
||||
return total
|
||||
`),
|
||||
).toBe(6)
|
||||
`)).toBe(6)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const m = new Map([["start", new Date(1000)]])
|
||||
return m.get("start").getTime()
|
||||
`),
|
||||
).toBe(1000)
|
||||
`)).toBe(1000)
|
||||
})
|
||||
|
||||
test("instanceof recognizes the stdlib value types", async () => {
|
||||
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])
|
||||
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 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(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`),
|
||||
).toBe(true)
|
||||
expect(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 () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(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 rows = JSON.parse(raw)
|
||||
const tags = new Set()
|
||||
@@ -419,34 +363,27 @@ describe("stdlib integration", () => {
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + 1)
|
||||
}
|
||||
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", () => {
|
||||
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(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe(
|
||||
"d:0",
|
||||
)
|
||||
expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe("d:0")
|
||||
})
|
||||
|
||||
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(
|
||||
1,
|
||||
)
|
||||
expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(1)
|
||||
})
|
||||
|
||||
test("object and array spread keep sandbox values usable", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
expect(await value(`
|
||||
const src = { m: new Map([["a", 1]]) }
|
||||
const copy = { ...src }
|
||||
copy.m.set("b", 2)
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({
|
||||
d: "1970-01-01T00:00:00.000Z",
|
||||
m: {},
|
||||
})
|
||||
expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ 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"}')
|
||||
|
||||
const observed: Array<unknown> = []
|
||||
@@ -483,12 +417,10 @@ describe("sandbox values at intra-sandbox checkpoints", () => {
|
||||
return "ok"
|
||||
}),
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { capture } },
|
||||
code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`,
|
||||
}),
|
||||
)
|
||||
const result = await Effect.runPromise(CodeMode.execute({
|
||||
tools: { host: { capture } },
|
||||
code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`,
|
||||
}))
|
||||
expect(result.ok).toBe(true)
|
||||
expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }])
|
||||
})
|
||||
|
||||
@@ -77,12 +77,7 @@ const layer = Layer.effect(
|
||||
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const api =
|
||||
// A native api with a package is explicitly targeted; only package-less,
|
||||
// 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
|
||||
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
||||
? { ...provider.api, id: model.api.id }
|
||||
: model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url
|
||||
? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||
|
||||
@@ -53,9 +53,6 @@ export const Plugin = define({
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.package !== undefined || item.settings !== undefined) {
|
||||
provider.api = configuredApi(provider.api, item.package, item.settings)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
@@ -66,16 +63,6 @@ export const Plugin = define({
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
if (config.package !== undefined || config.settings !== undefined) {
|
||||
model.api = {
|
||||
...configuredApi(
|
||||
config.api === undefined ? catalog.provider.get(providerID)!.provider.api : model.api,
|
||||
config.package,
|
||||
config.settings,
|
||||
),
|
||||
id: model.api.id,
|
||||
}
|
||||
}
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
@@ -124,25 +111,3 @@ export const Plugin = define({
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function configuredApi(api: ProviderV2.MutableApi, packageName?: string, settings?: Record<string, unknown>) {
|
||||
const merged = { ...api.settings, ...settings }
|
||||
const baseURL = typeof merged.baseURL === "string" ? merged.baseURL : api.url
|
||||
if (packageName?.startsWith("aisdk:")) {
|
||||
return {
|
||||
type: "aisdk" as const,
|
||||
package: packageName.slice("aisdk:".length),
|
||||
...(baseURL === undefined ? {} : { url: baseURL }),
|
||||
settings: merged,
|
||||
}
|
||||
}
|
||||
if (packageName !== undefined) {
|
||||
return {
|
||||
type: "native" as const,
|
||||
package: packageName,
|
||||
...(baseURL === undefined ? {} : { url: baseURL }),
|
||||
settings: merged,
|
||||
}
|
||||
}
|
||||
return { ...api, ...(baseURL === undefined ? {} : { url: baseURL }), settings: merged }
|
||||
}
|
||||
|
||||
@@ -47,8 +47,6 @@ const ModelApi = Schema.Union([
|
||||
class Model extends Schema.Class<Model>("ConfigV2.Model")({
|
||||
family: ModelV2.Family.pipe(Schema.optional),
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
api: ModelApi.pipe(Schema.optional),
|
||||
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
|
||||
request: Schema.Struct({
|
||||
@@ -67,8 +65,6 @@ class Model extends Schema.Class<Model>("ConfigV2.Model")({
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
api: ProviderV2.Api.pipe(Schema.optional),
|
||||
request: Request.pipe(Schema.optional),
|
||||
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
||||
|
||||
@@ -230,7 +230,7 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
|
||||
if (existing && existing !== node) {
|
||||
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([])
|
||||
}
|
||||
if (node.kind === "unbound") {
|
||||
|
||||
@@ -89,10 +89,6 @@ export function buildLocationServiceMap(
|
||||
LayerMap.make(
|
||||
(ref: Location.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)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
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 { Credential } from "../../credential"
|
||||
import { EventV2 } from "../../event"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
@@ -18,7 +17,6 @@ const callbackPort = 1455
|
||||
const pollingSafetyMargin = 3000
|
||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||
const codexPackage = "@opencode-ai/llm/providers/openai/codex"
|
||||
|
||||
type Pkce = {
|
||||
verifier: string
|
||||
@@ -156,55 +154,24 @@ const headless = {
|
||||
export const OpenAIPlugin = define({
|
||||
id: "openai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(browser)
|
||||
draft.method.update(headless)
|
||||
})
|
||||
yield* ctx.catalog.transform(
|
||||
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()) {
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai") continue
|
||||
if (item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) {
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
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) => {
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
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(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
@@ -302,34 +269,16 @@ function authorizeURL(redirect: string, pkce: Pkce, state: string) {
|
||||
codex_cli_simplified_flow: "true",
|
||||
state,
|
||||
originator: "opencode",
|
||||
}).toString()}`
|
||||
})}`
|
||||
}
|
||||
|
||||
function extractAccountID(tokens: TokenResponse) {
|
||||
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) {
|
||||
const part = token.split(".")[1]
|
||||
if (!part) return undefined
|
||||
if (!part) return
|
||||
try {
|
||||
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
|
||||
return (
|
||||
@@ -338,6 +287,6 @@ function claim(token: string) {
|
||||
claims.organizations?.[0]?.id
|
||||
)
|
||||
} catch {
|
||||
return undefined
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
export * as ProviderV2 from "./provider"
|
||||
|
||||
import { Effect, Schema, Types } from "effect"
|
||||
import { Types } from "effect"
|
||||
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 type ID = typeof ID.Type
|
||||
@@ -25,44 +23,3 @@ export const Info = Provider.Info
|
||||
export type Info = Provider.Info
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export * as SessionRunnerModel from "./model"
|
||||
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { Model } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Predicate, Schema } from "effect"
|
||||
import { type Model } from "@opencode-ai/llm"
|
||||
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 { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
@@ -65,7 +69,6 @@ export type Error =
|
||||
| ModelUnavailableError
|
||||
| VariantUnavailableError
|
||||
| UnsupportedApiError
|
||||
| ProviderV2.PackageLoadError
|
||||
| Integration.AuthorizationError
|
||||
|
||||
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. */
|
||||
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 = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
@@ -107,95 +131,52 @@ const apiName = (model: ModelV2.Info) =>
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
credential?: Credential.Value,
|
||||
): Effect.Effect<Model, UnsupportedApiError | ProviderV2.PackageLoadError> => {
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const resolved =
|
||||
credential?.type !== "key" || credential.metadata === undefined
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
Object.assign(draft.request.body, credential.metadata)
|
||||
})
|
||||
return packageSpecifier(resolved).pipe(
|
||||
Effect.flatMap((specifier) =>
|
||||
ProviderV2.loadPackage(specifier).pipe(
|
||||
Effect.map((load) => {
|
||||
const selected = load(resolved.api.id ?? resolved.id, {
|
||||
...resolved.api.settings,
|
||||
...(credential?.type === "oauth" ? credential.metadata : undefined),
|
||||
baseURL: resolved.api.url ?? settingsString(resolved.api.settings, "baseURL"),
|
||||
apiKey: apiKey(resolved, credential),
|
||||
providerOptions: requestSettings(resolved.request),
|
||||
headers: resolved.request.headers,
|
||||
body: stripApiKey(resolved.request.body),
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
})
|
||||
return Model.update(selected, {
|
||||
provider: resolved.providerID,
|
||||
route: selected.route.with({ provider: resolved.providerID }),
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
const key = apiKey(resolved, credential)
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
|
||||
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) =>
|
||||
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) =>
|
||||
(model.api.type === "native" && model.api.package !== undefined) ||
|
||||
(model.api.type === "aisdk" &&
|
||||
aisdkPackages[model.api.package] !== undefined &&
|
||||
// The openai-compatible package has no default endpoint; a URL is required.
|
||||
(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
|
||||
}
|
||||
model.api.type === "aisdk" &&
|
||||
(model.api.package === "@ai-sdk/openai" ||
|
||||
model.api.package === "@ai-sdk/anthropic" ||
|
||||
(model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined))
|
||||
|
||||
/** Resolves models from the catalog belonging to the current Location runtime. */
|
||||
export const locationLayer = Layer.effect(
|
||||
|
||||
@@ -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", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -55,76 +55,6 @@ function request(headers: Record<string, string>, variant?: string) {
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("loads documented native provider packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode-ai/llm/providers/openai-compatible",
|
||||
settings: { baseURL: "https://example.test/v1" },
|
||||
models: { chat: { name: "Chat" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* addPlugin(config)
|
||||
|
||||
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
id: modelID,
|
||||
type: "native",
|
||||
package: "@opencode-ai/llm/providers/openai-compatible",
|
||||
url: "https://example.test/v1",
|
||||
settings: { baseURL: "https://example.test/v1" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads documented aisdk provider packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://example.test/v1" },
|
||||
models: { chat: { name: "Chat" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* addPlugin(config)
|
||||
|
||||
expect(required(yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
id: modelID,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://example.test/v1",
|
||||
settings: { baseURL: "https://example.test/v1" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps configured model variant bodies unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
@@ -17,6 +16,7 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const integrations = yield* Integration.Service
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
@@ -77,6 +78,7 @@ describe("OpenAIPlugin", () => {
|
||||
|
||||
it.effect("ignores non-OpenAI SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
@@ -93,6 +95,7 @@ describe("OpenAIPlugin", () => {
|
||||
|
||||
it.effect("uses the Responses API for language models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -111,6 +114,7 @@ describe("OpenAIPlugin", () => {
|
||||
|
||||
it.effect("ignores non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -169,54 +173,4 @@ describe("OpenAIPlugin", () => {
|
||||
).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 settings?: Record<string, unknown>
|
||||
}
|
||||
| {
|
||||
readonly type: "native"
|
||||
readonly package?: string
|
||||
readonly url?: string
|
||||
readonly settings: Record<string, unknown>
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: Record<string, unknown> }
|
||||
|
||||
const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||
@@ -412,11 +342,6 @@ describe("SessionRunnerModel", () => {
|
||||
),
|
||||
).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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
".": "./src/index.ts",
|
||||
"./route": "./src/route/index.ts",
|
||||
"./provider": "./src/provider.ts",
|
||||
"./provider-package": "./src/provider-package.ts",
|
||||
"./providers": "./src/providers/index.ts",
|
||||
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
|
||||
"./providers/anthropic": "./src/providers/anthropic.ts",
|
||||
@@ -23,7 +22,6 @@
|
||||
"./providers/github-copilot": "./src/providers/github-copilot.ts",
|
||||
"./providers/google": "./src/providers/google.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-profile": "./src/providers/openai-compatible-profile.ts",
|
||||
"./providers/openrouter": "./src/providers/openrouter.ts",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { LLMClient } from "./route/client"
|
||||
export { Auth } from "./route/auth"
|
||||
export { Provider } from "./provider"
|
||||
export { ProviderPackage } from "./provider-package"
|
||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
||||
export type {
|
||||
RouteModelInput,
|
||||
|
||||
@@ -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
|
||||
@@ -2,16 +2,13 @@ import type { RouteDefaultsInput } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import type { ProviderAuthOption } from "../route/auth-options"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import { ProviderPackage } from "../provider-package"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||
import * as AnthropicMessages from "../protocols/anthropic-messages"
|
||||
|
||||
export const id = ProviderID.make("anthropic")
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
|
||||
type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
||||
|
||||
export interface AnthropicSettings extends ProviderPackage.Settings {}
|
||||
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
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) =>
|
||||
AnthropicMessages.route
|
||||
.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 }),
|
||||
)
|
||||
export const provider = configure()
|
||||
export const model = provider.model
|
||||
|
||||
@@ -6,7 +6,6 @@ export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
|
||||
export * as GitHubCopilot from "./github-copilot"
|
||||
export * as Google from "./google"
|
||||
export * as OpenAI from "./openai"
|
||||
export * as OpenAICodex from "./openai/codex"
|
||||
export * as OpenAICompatible from "./openai-compatible"
|
||||
export * as OpenRouter from "./openrouter"
|
||||
export * as XAI from "./xai"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import { Auth } from "../route/auth"
|
||||
import { ProviderPackage } from "../provider-package"
|
||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
|
||||
|
||||
export const id = ProviderID.make("openai-compatible")
|
||||
|
||||
@@ -13,7 +12,10 @@ type GenericModelOptions = RouteDefaultsInput &
|
||||
readonly baseURL: string
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleSettings extends ProviderPackage.Settings {}
|
||||
export type FamilyModelOptions = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleChat.route]
|
||||
|
||||
@@ -33,15 +35,31 @@ export const configure = (input: GenericModelOptions) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model = ProviderPackage.define((modelID, settings: OpenAICompatibleSettings) =>
|
||||
OpenAICompatibleChat.route
|
||||
.with({
|
||||
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey),
|
||||
endpoint: { baseURL: settings.baseURL },
|
||||
headers: settings.headers,
|
||||
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
|
||||
http: { body: settings.body },
|
||||
limits: settings.limits,
|
||||
const define = (profile: OpenAICompatibleProfile) => {
|
||||
const configureProfile = (input: FamilyModelOptions = {}) => {
|
||||
const facade = configure({
|
||||
...input,
|
||||
baseURL: input.baseURL ?? profile.baseURL,
|
||||
provider: profile.provider,
|
||||
})
|
||||
.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)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import { Auth } from "../route/auth"
|
||||
import type { Route, RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import { ProviderPackage } from "../provider-package"
|
||||
import { OpenAIChat } from "../protocols/openai-chat"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } 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
|
||||
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
||||
// and default option normalization.
|
||||
type Config = RouteDefaultsInput &
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly queryParams?: Record<string, string>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface OpenAISettings extends ProviderPackage.Settings {}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||
|
||||
const defaults = (input: Config) => {
|
||||
@@ -59,15 +55,9 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model = ProviderPackage.define((modelID, settings: OpenAISettings) =>
|
||||
OpenAIResponses.route
|
||||
.with({
|
||||
auth: settings.apiKey === undefined ? Auth.none : Auth.bearer(settings.apiKey),
|
||||
endpoint: { baseURL: settings.baseURL },
|
||||
headers: settings.headers,
|
||||
providerOptions: settings.providerOptions && { openai: settings.providerOptions },
|
||||
http: { body: settings.body },
|
||||
limits: settings.limits,
|
||||
})
|
||||
.model({ id: modelID }),
|
||||
)
|
||||
export const provider = configure()
|
||||
|
||||
export const model = provider.model
|
||||
export const responses = provider.responses
|
||||
export const responsesWebSocket = provider.responsesWebSocket
|
||||
export const chat = provider.chat
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
@@ -42,41 +42,40 @@ export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
|
||||
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 =>
|
||||
isRecord(value) &&
|
||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||
"value" in value
|
||||
|
||||
export const ToolResultValue = Object.assign(toolResultValueSchema, {
|
||||
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 const ToolResultValue = Object.assign(
|
||||
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" }),
|
||||
{
|
||||
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 {
|
||||
readonly structured: unknown
|
||||
|
||||
@@ -67,6 +67,7 @@ requiredAuthModel("custom-model", {})
|
||||
// @ts-expect-error auth is an override, so apiKey cannot be supplied with it.
|
||||
requiredAuthModel("custom-model", { apiKey: "key", auth })
|
||||
|
||||
OpenAI.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: 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.
|
||||
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: configApiKey }).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.
|
||||
XAI.configure({ apiKey: "xai-key" }).chat("grok-4", {})
|
||||
|
||||
OpenAICompatible.configure({
|
||||
apiKey: "deepseek-key",
|
||||
provider: "deepseek",
|
||||
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", {})
|
||||
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat")
|
||||
// @ts-expect-error OpenAI-compatible family selectors only accept model ids.
|
||||
OpenAICompatible.deepseek.configure({ apiKey: "deepseek-key" }).model("deepseek-chat", {})
|
||||
|
||||
Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key" }).model("@cf/meta/llama")
|
||||
// @ts-expect-error Cloudflare Workers AI model selectors only accept model ids.
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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 { Provider as ProviderSubpath } from "@opencode-ai/llm/provider"
|
||||
import { ProviderPackage as ProviderPackageSubpath } from "@opencode-ai/llm/provider-package"
|
||||
import {
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
OpenAI,
|
||||
OpenAICodex,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
XAI,
|
||||
@@ -23,8 +21,6 @@ describe("public exports", () => {
|
||||
expect(LLMClient.layer).toBeDefined()
|
||||
expect(Provider.make).toBeFunction()
|
||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||
expect(ProviderPackage.define).toBeFunction()
|
||||
expect(ProviderPackageSubpath.define).toBe(ProviderPackage.define)
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
@@ -34,12 +30,11 @@ describe("public exports", () => {
|
||||
|
||||
test("provider barrels expose user-facing facades", () => {
|
||||
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(OpenAICodex.model).toBeFunction()
|
||||
expect(OpenAICompatible.model).toBeFunction()
|
||||
expect(
|
||||
OpenAICompatible.configure({ baseURL: "https://api.compatible.test/v1", apiKey: "fixture" }).model,
|
||||
).toBeFunction()
|
||||
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
||||
expect(CloudflareAIGateway.configure).toBeFunction()
|
||||
expect(CloudflareAIGateway.configure({ accountId: "fixture", gatewayApiKey: "fixture" }).model).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 cloudflareWorkersAI = cloudflareWorkers.model("@cf/meta/llama-3.1-8b-instruct")
|
||||
const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b")
|
||||
const deepseek = OpenAICompatible.configure({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture",
|
||||
provider: "deepseek",
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
}).model("deepseek-chat")
|
||||
const together = OpenAICompatible.configure({
|
||||
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
|
||||
provider: "togetherai",
|
||||
baseURL: "https://api.together.xyz/v1",
|
||||
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
|
||||
const groq = OpenAICompatible.configure({
|
||||
apiKey: process.env.GROQ_API_KEY ?? "fixture",
|
||||
provider: "groq",
|
||||
baseURL: "https://api.groq.com/openai/v1",
|
||||
}).model("llama-3.3-70b-versatile")
|
||||
const deepseek = OpenAICompatible.deepseek
|
||||
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
|
||||
.model("deepseek-chat")
|
||||
const together = OpenAICompatible.togetherai
|
||||
.configure({
|
||||
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
|
||||
})
|
||||
.model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
|
||||
const groq = OpenAICompatible.groq
|
||||
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
|
||||
.model("llama-3.3-70b-versatile")
|
||||
const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
|
||||
const openrouter = openRouter.model("openai/gpt-4o-mini")
|
||||
const openrouterGpt55 = openRouter.model("openai/gpt-5.5")
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolCallPart } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
@@ -39,6 +40,15 @@ const usageChunk = (usage: object) => ({
|
||||
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", () => {
|
||||
it.effect("prepares generic Chat target", () =>
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
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"),
|
||||
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
|
||||
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
|
||||
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { type Tool } from "ai"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
@@ -153,19 +154,17 @@ export interface ServerInstructions {
|
||||
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 {
|
||||
readonly status: () => Effect.Effect<Record<string, Status>>
|
||||
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
||||
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 resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||
readonly resourceTemplates: (
|
||||
@@ -663,7 +662,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const tools = Effect.fn("MCP.tools")(function* () {
|
||||
const result: Record<string, McpTool> = {}
|
||||
const result: Record<string, Tool> = {}
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
@@ -679,8 +678,21 @@ const layer = Layer.effect(
|
||||
continue
|
||||
}
|
||||
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
|
||||
for (const def of listed) {
|
||||
result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout }
|
||||
for (const mcpTool of listed) {
|
||||
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
|
||||
@@ -988,6 +1000,7 @@ const layer = Layer.effect(
|
||||
clients,
|
||||
instructions,
|
||||
tools,
|
||||
defs,
|
||||
prompts,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
|
||||
@@ -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> {
|
||||
const hidden = disabled(Object.keys(tools), ruleset)
|
||||
return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name)))
|
||||
|
||||
@@ -292,6 +292,8 @@ export function createRoutes(
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
|
||||
Layer.provide(sessionLocationLayer),
|
||||
Layer.provide(locationLayer),
|
||||
Layer.provide(PtyEnvironment.layer),
|
||||
@@ -304,11 +306,6 @@ export function createRoutes(
|
||||
Layer.provide(locationServiceMapV2),
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -206,11 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
|
||||
})
|
||||
|
||||
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
|
||||
const disabled = Permission.disabled(
|
||||
Object.keys(input.tools),
|
||||
const visible = Permission.visibleTools(
|
||||
input.tools,
|
||||
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 {
|
||||
|
||||
@@ -192,8 +192,6 @@ const layer = Layer.effect(
|
||||
status: "error",
|
||||
input: match.part.state.input,
|
||||
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() },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1237,6 +1237,8 @@ const layer = Layer.effect(
|
||||
Effect.provideService(ToolRegistry.Service, registry),
|
||||
Effect.provideService(MCP.Service, mcp),
|
||||
Effect.provideService(Truncate.Service, truncate),
|
||||
Effect.provideService(Agent.Service, agents),
|
||||
Effect.provideService(RuntimeFlags.Service, flags),
|
||||
)
|
||||
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { McpInvoke } from "@/mcp/invoke"
|
||||
import { Permission } from "@/permission"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
@@ -22,6 +22,7 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const MCP_RESOURCE_TOOLS = {
|
||||
list: "list_mcp_resources",
|
||||
@@ -53,6 +54,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
|
||||
sessionID: input.session.id,
|
||||
@@ -87,11 +89,20 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
.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),
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
permission: input.session.permission,
|
||||
})
|
||||
|
||||
for (const item of registryTools) {
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
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())) {
|
||||
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
|
||||
if (codeMode) return tools
|
||||
|
||||
for (const [key, item] of Object.entries(mcpTools)) {
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
@@ -394,29 +406,19 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
run.promise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, opts)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
return yield* Effect.promise(() => execute(args, opts))
|
||||
}).pipe(
|
||||
Effect.withSpan("Tool.execute", {
|
||||
attributes: {
|
||||
"tool.name": key,
|
||||
"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,
|
||||
)
|
||||
// Shared MCP middle (before hook → permission ask → Tool.execute span →
|
||||
// dispatch → after hook); this caller keeps the model-facing shaping edge below.
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* McpInvoke.invoke({
|
||||
plugin,
|
||||
key,
|
||||
execute,
|
||||
args,
|
||||
callID: opts.toolCallId,
|
||||
options: opts,
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
ask: ctx.ask,
|
||||
})
|
||||
|
||||
const textParts: string[] = []
|
||||
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
CodeMode,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from "@opencode-ai/codemode"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { McpInvoke } from "@/mcp/invoke"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Session } from "@/session/session"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -18,6 +20,18 @@ import { Plugin } from "@/plugin"
|
||||
|
||||
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 = [
|
||||
"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.",
|
||||
@@ -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 = {
|
||||
toolCalls: CallEntry[]
|
||||
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
|
||||
key: string
|
||||
server: 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 groups = new Map<string, CatalogEntry[]>()
|
||||
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
|
||||
const server =
|
||||
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
|
||||
const server = 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 def = mcpDefs[key]
|
||||
const entry: CatalogEntry = {
|
||||
path: `${server}.${local}`,
|
||||
key,
|
||||
server,
|
||||
local,
|
||||
description: mcpTools[key]!.description ?? def?.description ?? "",
|
||||
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])
|
||||
}
|
||||
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({
|
||||
tools: toolTree(
|
||||
[...groupByServer(mcpTools, servers).values()].flat(),
|
||||
() => () => Effect.fail(toolError("Tool preview is not executable.")),
|
||||
),
|
||||
tools: toolTree(catalog, () => () => Effect.fail(toolError("Tool preview is not executable."))),
|
||||
}).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 trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
|
||||
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}`
|
||||
|
||||
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[] = []
|
||||
let files = 0
|
||||
let images = 0
|
||||
@@ -95,109 +194,105 @@ function projectMcpResult(result: CallToolResult, collect: (attachment: Attachme
|
||||
if (attachment.mime.startsWith("image/")) images += 1
|
||||
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) {
|
||||
case "text":
|
||||
text.push(block.text)
|
||||
if (typeof block.text === "string") text.push(block.text)
|
||||
break
|
||||
case "image":
|
||||
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
|
||||
case "resource": {
|
||||
if ("text" in block.resource) {
|
||||
text.push(block.resource.text)
|
||||
break
|
||||
const res = block.resource as Record<string, unknown> | undefined
|
||||
if (res && typeof res === "object") {
|
||||
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
|
||||
}
|
||||
case "resource_link":
|
||||
// A link is a reference, not fetchable media; hand it to the program instead of the attachment channel.
|
||||
text.push(`${block.name}: ${block.uri}`)
|
||||
if (typeof block.uri === "string") {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 (files > 0) {
|
||||
const noun = files === images ? "image" : "file"
|
||||
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
|
||||
if (files > 0) return mediaMarker(files, images)
|
||||
if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable
|
||||
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>
|
||||
|
||||
/** 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) {
|
||||
const tree: Record<string, Record<string, ToolDefinition>> = {}
|
||||
for (const entry of catalog) {
|
||||
const namespace = (tree[entry.server] ??= {})
|
||||
namespace[entry.local] = SandboxTool.make({
|
||||
description: entry.tool.def.description ?? "",
|
||||
input: entry.tool.def.inputSchema as JsonSchema,
|
||||
output: entry.tool.def.outputSchema as JsonSchema | undefined,
|
||||
description: entry.description,
|
||||
input: entry.inputSchema,
|
||||
output: entry.outputSchema,
|
||||
run: run(entry),
|
||||
})
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: {
|
||||
plugin: Plugin.Interface
|
||||
entry: CatalogEntry
|
||||
args: Record<string, unknown>
|
||||
callID: string
|
||||
ctx: Tool.Context
|
||||
}) {
|
||||
yield* input.plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID },
|
||||
{ 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,
|
||||
},
|
||||
/** Failures inside a child call — plugin hook failures, permission denials, and tool
|
||||
* failures alike — become safe, catchable in-program errors via toolError, so a
|
||||
* program can try/catch one call without the whole execution dying. Interruption
|
||||
* (user cancel) keeps propagating as interruption. */
|
||||
const toCatchable = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error))
|
||||
}),
|
||||
)
|
||||
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(
|
||||
CODE_MODE_TOOL,
|
||||
@@ -211,6 +306,8 @@ export const CodeModeTool = Tool.define(
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
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) {
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
@@ -218,35 +315,53 @@ export const CodeModeTool = Tool.define(
|
||||
output: "Execution cancelled.",
|
||||
} 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 session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie)
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset)
|
||||
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[] = []
|
||||
// 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 publish = () =>
|
||||
ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } })
|
||||
const collect = (attachment: Attachment) => void attachments.push(attachment)
|
||||
// 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
|
||||
const callTool = (entry: CatalogEntry) => (input: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
childCalls += 1
|
||||
const result = yield* invokeChildTool({
|
||||
plugin,
|
||||
entry,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
|
||||
ctx,
|
||||
})
|
||||
return projectMcpResult(result, (attachment: Attachment) => void attachments.push(attachment))
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error))
|
||||
toCatchable(
|
||||
Effect.gen(function* () {
|
||||
childCalls += 1
|
||||
const raw = yield* McpInvoke.invoke({
|
||||
plugin,
|
||||
key: entry.key,
|
||||
execute: entry.tool.execute!,
|
||||
args: input ?? {},
|
||||
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
|
||||
options: { toolCallId: ctx.callID ?? entry.key, abortSignal: ctx.abort, messages: [] },
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.messageID,
|
||||
ask: ctx.ask,
|
||||
})
|
||||
return toSandboxResult(raw, collect)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -254,14 +369,7 @@ export const CodeModeTool = Tool.define(
|
||||
tools: toolTree(catalog, callTool),
|
||||
onToolCallStart: ({ index, name, input }) =>
|
||||
Effect.suspend(() => {
|
||||
const shown = (() => {
|
||||
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 }
|
||||
})()
|
||||
const shown = displayInput(input)
|
||||
calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||
return publish()
|
||||
}),
|
||||
@@ -273,51 +381,47 @@ export const CodeModeTool = Tool.define(
|
||||
}),
|
||||
})
|
||||
|
||||
const abort = Effect.callback<void>((resume) => {
|
||||
if (ctx.abort.aborted) return resume(Effect.void)
|
||||
const handler = () => resume(Effect.void)
|
||||
ctx.abort.addEventListener("abort", handler, { once: true })
|
||||
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
|
||||
})
|
||||
const cancelled = (): ExecuteResult => ({
|
||||
ok: false,
|
||||
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
|
||||
toolCalls: calls.map((call) => ({ name: call.tool })),
|
||||
// The shared tool runner does not wire ctx.abort to fiber interruption (it runs
|
||||
// tools via Effect.runPromise with no abort handling), so without this race the
|
||||
// program would keep running after the user cancels. The abort signal winning the
|
||||
// race interrupts the execution fiber; the cancelled result keeps the runner's
|
||||
// post-abort bookkeeping (completeToolCall) on its normal path.
|
||||
const cancelled = Effect.callback<ExecuteResult>((resume) => {
|
||||
const onAbort = () =>
|
||||
resume(
|
||||
Effect.succeed<ExecuteResult>({
|
||||
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 withLogs = (text: string) => {
|
||||
if (logs.length === 0) return text
|
||||
return text.length > 0 ? `${text}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`
|
||||
const attached = attachments.length > 0 ? { attachments } : {}
|
||||
|
||||
if (result.ok) {
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
metadata: { toolCalls: calls },
|
||||
output: withLogs(formatValue(result.value), logs),
|
||||
...attached,
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
if (ctx.abort.aborted) {
|
||||
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))
|
||||
|
||||
// Diagnostics may carry suggestions (e.g. pointing an unknown tool at
|
||||
// discovery); append the ones the message doesn't already contain.
|
||||
const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
metadata: { toolCalls: calls },
|
||||
output: withLogs(output),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
metadata: { toolCalls: calls, error: true },
|
||||
output: withLogs([result.error.message, ...hints].join("\n"), logs),
|
||||
...attached,
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}, Effect.orDie),
|
||||
}),
|
||||
}
|
||||
return init
|
||||
}),
|
||||
|
||||
@@ -26,6 +26,9 @@ import { Plugin } from "../plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { CodeModeTool, catalogInstructions } from "./code-mode"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { LspTool } from "./lsp"
|
||||
import * as Truncate from "./truncate"
|
||||
import { ApplyPatchTool } from "./apply_patch"
|
||||
@@ -51,6 +54,7 @@ import { BackgroundJob } from "@/background/job"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
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 }) {
|
||||
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
|
||||
@@ -74,6 +78,7 @@ export interface Interface {
|
||||
providerID: ProviderV2.ID
|
||||
modelID: ModelV2.ID
|
||||
agent: Agent.Info
|
||||
permission?: PermissionV1.Ruleset
|
||||
}) => Effect.Effect<Tool.Def[]>
|
||||
}
|
||||
|
||||
@@ -87,6 +92,7 @@ const layer = Layer.effect(
|
||||
const agents = yield* Agent.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const mcp = yield* MCP.Service
|
||||
|
||||
const invalid = yield* InvalidTool
|
||||
const task = yield* TaskTool
|
||||
@@ -104,6 +110,7 @@ const layer = Layer.effect(
|
||||
const greptool = yield* GrepTool
|
||||
const patchtool = yield* ApplyPatchTool
|
||||
const skilltool = yield* SkillTool
|
||||
const codemode = yield* CodeModeTool
|
||||
const agent = yield* Agent.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
@@ -211,6 +218,7 @@ const layer = Layer.effect(
|
||||
question: Tool.init(question),
|
||||
lsp: Tool.init(lsptool),
|
||||
plan: Tool.init(plan),
|
||||
codemode: Tool.init(codemode),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -232,6 +240,7 @@ const layer = Layer.effect(
|
||||
tool.patch,
|
||||
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
||||
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
|
||||
...(flags.experimentalCodeMode ? [tool.codemode] : []),
|
||||
],
|
||||
task: tool.task,
|
||||
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")
|
||||
})
|
||||
|
||||
// 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) {
|
||||
// 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) => {
|
||||
if (tool.id === WebSearchTool.id) {
|
||||
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
|
||||
}
|
||||
if (tool.id === CodeModeTool.id) return flags.experimentalCodeMode && mcpToolCount > 0
|
||||
|
||||
const usePatch =
|
||||
input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
|
||||
@@ -292,7 +315,11 @@ const layer = Layer.effect(
|
||||
: undefined
|
||||
return {
|
||||
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)
|
||||
.join("\n"),
|
||||
parameters: output.parameters,
|
||||
@@ -314,6 +341,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
function isZodType(value: unknown): value is z.ZodType {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
@@ -406,6 +434,7 @@ export const node = LayerNode.make({
|
||||
LSP.node,
|
||||
Instruction.node,
|
||||
FSUtil.node,
|
||||
MCP.node,
|
||||
EventV2Bridge.node,
|
||||
httpClient,
|
||||
CrossSpawnSpawner.node,
|
||||
|
||||
@@ -117,6 +117,7 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) {
|
||||
clients: () => Effect.succeed({}),
|
||||
instructions: () => Effect.succeed(instructions),
|
||||
tools: () => Effect.succeed({}),
|
||||
defs: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => Effect.succeed({}),
|
||||
resourceTemplates: () => Effect.succeed({}),
|
||||
|
||||
@@ -39,6 +39,7 @@ const mcp = Layer.succeed(
|
||||
clients: () => Effect.succeed({}),
|
||||
instructions: () => Effect.succeed([]),
|
||||
tools: () => Effect.succeed({}),
|
||||
defs: () => Effect.succeed({}),
|
||||
prompts: () => Effect.succeed({}),
|
||||
resources: () => 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 { CodeModeTool, describeCatalog } from "@/tool/code-mode"
|
||||
import { CodeModeTool, catalogInstructions } from "@/tool/code-mode"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { MCP } from "@/mcp"
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
type Tool as MCPToolDef,
|
||||
} 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"
|
||||
|
||||
@@ -34,7 +37,16 @@ const ctx: Tool.Context = {
|
||||
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 {
|
||||
private nextId = 1
|
||||
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>()
|
||||
@@ -44,7 +56,7 @@ class RawJsonRpcClient {
|
||||
async connect() {
|
||||
this.transport.onmessage = (message) => {
|
||||
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)
|
||||
if (!entry) return
|
||||
this.pending.delete(msg.id)
|
||||
@@ -71,11 +83,16 @@ class RawJsonRpcClient {
|
||||
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) {
|
||||
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[] = [
|
||||
{
|
||||
name: "get_text",
|
||||
@@ -133,15 +150,21 @@ async function buildTool() {
|
||||
await client.connect()
|
||||
|
||||
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) {
|
||||
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(
|
||||
Layer.mock(Plugin.Service, {
|
||||
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"],
|
||||
trigger: (((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"]),
|
||||
}),
|
||||
Layer.mock(Truncate.Service, {
|
||||
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(MCP.Service, {
|
||||
tools: () => Effect.succeed(mcpTools),
|
||||
defs: () => Effect.succeed(mcpDefs),
|
||||
clients: () => Effect.succeed({ [SERVER]: {} as any }),
|
||||
}),
|
||||
)
|
||||
return {
|
||||
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))
|
||||
// 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 () => {
|
||||
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", () => {
|
||||
expect(description).toContain("Available tools (COMPLETE list")
|
||||
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("// Add two numbers and return the structured sum")
|
||||
// Small catalog: everything is inline, so no discovery tool is advertised.
|
||||
expect(description).not.toContain("$codemode")
|
||||
// The workflow section is present with placeholder-only call forms.
|
||||
expect(description).toContain("## Workflow")
|
||||
expect(description).toContain("`const res = await tools.<namespace>.<tool>(input)`")
|
||||
expect(description).not.toContain("total_count")
|
||||
@@ -229,6 +252,7 @@ describe("code mode integration (real MCP server)", () => {
|
||||
value: "[1 image attached to the result]",
|
||||
})
|
||||
expect(out.output).not.toContain(PNG)
|
||||
// The stripped image still arrives as a real attachment.
|
||||
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 () => {
|
||||
const error = await runFailed("await tools.fixtures.boom({}); return 'unreachable'")
|
||||
expect(error.message).toContain("kaboom")
|
||||
const out = await run("await tools.fixtures.boom({}); return 'unreachable'")
|
||||
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 () => {
|
||||
@@ -270,13 +295,14 @@ describe("code mode integration (real MCP server)", () => {
|
||||
})
|
||||
|
||||
test("console output is preserved on the error path", async () => {
|
||||
const error = await runFailed(`
|
||||
const out = await run(`
|
||||
console.log("before the throw")
|
||||
await tools.fixtures.boom({})
|
||||
return "unreachable"
|
||||
`)
|
||||
expect(error.message).toContain("kaboom")
|
||||
expect(error.message).toContain("Logs:\nbefore the throw")
|
||||
expect(out.metadata.error).toBe(true)
|
||||
expect(out.output).toContain("kaboom")
|
||||
expect(out.output).toContain("Logs:\nbefore the throw")
|
||||
})
|
||||
|
||||
test("a program that logs nothing gets no Logs section", async () => {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
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 { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { Agent } from "@/agent/agent"
|
||||
@@ -9,8 +19,10 @@ import { Plugin } from "@/plugin"
|
||||
import { Session } from "@/session/session"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
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 = {
|
||||
sessionID: SessionID.make("ses_code-mode"),
|
||||
@@ -23,22 +35,28 @@ const ctx: Tool.Context = {
|
||||
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(
|
||||
name: string,
|
||||
handler: (args: Record<string, unknown>) => unknown,
|
||||
inputSchema: Record<string, unknown> = { type: "object", properties: {} },
|
||||
outputSchema?: Record<string, unknown>,
|
||||
): MCP.McpTool {
|
||||
return {
|
||||
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"],
|
||||
): AITool {
|
||||
const client = {
|
||||
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
|
||||
}
|
||||
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: {
|
||||
mcpTools: Record<string, MCP.McpTool>
|
||||
mcpTools: Record<string, AITool>
|
||||
defs?: Record<string, MCPToolDef>
|
||||
servers: string[]
|
||||
permission?: PermissionV1.Rule[]
|
||||
trigger?: Plugin.Interface["trigger"]
|
||||
@@ -58,17 +76,21 @@ function harness(input: {
|
||||
}),
|
||||
Layer.mock(MCP.Service, {
|
||||
tools: () => Effect.succeed(input.mcpTools),
|
||||
defs: () => Effect.succeed(input.defs ?? {}),
|
||||
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]!))]
|
||||
}
|
||||
|
||||
function build(
|
||||
mcpTools: Record<string, MCP.McpTool>,
|
||||
mcpTools: Record<string, AITool>,
|
||||
defs: Record<string, MCPToolDef> = {},
|
||||
servers?: string[],
|
||||
permission?: PermissionV1.Rule[],
|
||||
trigger?: Plugin.Interface["trigger"],
|
||||
@@ -77,20 +99,20 @@ function build(
|
||||
return Effect.runPromise(
|
||||
CodeModeTool.pipe(
|
||||
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[] = []) {
|
||||
return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers))
|
||||
}
|
||||
|
||||
// Program failures die at the tool boundary; recover the defect for message assertions.
|
||||
async function failure(effect: Effect.Effect<unknown>) {
|
||||
const exit = await Effect.runPromise(effect.pipe(Effect.exit))
|
||||
if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail")
|
||||
return Cause.squash(exit.cause) as Error
|
||||
// The agent-facing description, as the registry composes it (`describeCodeMode`):
|
||||
// permission-filtered tool set → grouped catalog → CodeMode instructions.
|
||||
function describeFor(
|
||||
mcpTools: Record<string, AITool>,
|
||||
defs: Record<string, MCPToolDef> = {},
|
||||
servers?: string[],
|
||||
permission: PermissionV1.Rule[] = [],
|
||||
) {
|
||||
return catalogInstructions(Permission.visibleTools(mcpTools, permission), defs, serverNames(mcpTools, servers))
|
||||
}
|
||||
|
||||
describe("code mode execute", () => {
|
||||
@@ -101,30 +123,38 @@ describe("code mode execute", () => {
|
||||
})
|
||||
|
||||
test("groups multi-underscore server names by longest matching prefix", () => {
|
||||
const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
|
||||
expect(description).toContain("- my_server (1 tool)")
|
||||
expect(description).toContain("tools.my_server.do_thing(")
|
||||
const groups = groupByServer({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
|
||||
expect([...groups.keys()]).toEqual(["my_server"])
|
||||
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", () => {
|
||||
const description = describeFor({ standalone: mcpTool("standalone", () => "") }, [])
|
||||
expect(description).toContain("- standalone (1 tool)")
|
||||
expect(description).toContain("tools.standalone.standalone(")
|
||||
const groups = groupByServer({ standalone: mcpTool("standalone", () => "") }, [])
|
||||
expect([...groups.keys()]).toEqual(["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", () => {
|
||||
const description = describeFor(
|
||||
{
|
||||
weather_current: mcpTool(
|
||||
"current",
|
||||
() => "",
|
||||
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
{ type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
|
||||
),
|
||||
},
|
||||
["weather"],
|
||||
)
|
||||
expect(description).toContain("tools.weather.current(input: { city: string }): Promise<{ tempC: number }>")
|
||||
test("groupByServer carries the raw MCP schemas for rendering", () => {
|
||||
const defs: Record<string, MCPToolDef> = {
|
||||
weather_current: {
|
||||
name: "current",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
outputSchema: { type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
|
||||
} as any,
|
||||
}
|
||||
const groups = groupByServer({ weather_current: mcpTool("current", () => "") }, ["weather"], defs)
|
||||
const entry = groups.get("weather")![0]!
|
||||
expect(entry.inputSchema).toEqual(defs.weather_current!.inputSchema as any)
|
||||
expect(entry.outputSchema).toEqual(defs.weather_current!.outputSchema as any)
|
||||
})
|
||||
|
||||
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.linear.search(")
|
||||
// A schema with no properties renders as an empty object, not `{ }`.
|
||||
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("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("1. Pick a tool from the list under `## Available tools`")
|
||||
expect(description).toContain(
|
||||
'`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
|
||||
)
|
||||
expect(description).toContain('`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).not.toContain("total_count")
|
||||
})
|
||||
|
||||
test("signatures render the declared outputSchema as the return type", () => {
|
||||
const description = describeFor({
|
||||
weather_current: mcpTool(
|
||||
"current",
|
||||
() => "",
|
||||
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
{
|
||||
const defs: Record<string, MCPToolDef> = {
|
||||
weather_current: {
|
||||
name: "current",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { tempC: { type: "number" }, summary: { type: "string" } },
|
||||
required: ["tempC"],
|
||||
},
|
||||
),
|
||||
})
|
||||
} as any,
|
||||
}
|
||||
const description = describeFor({ weather_current: mcpTool("current", () => "") }, defs)
|
||||
expect(description).toContain(
|
||||
"tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>",
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
for (let i = 0; i < 150; i++) {
|
||||
tools[`alpha_op_${i}`] = {
|
||||
def: {
|
||||
const client = { callTool: async () => ({ content: [] }) }
|
||||
tools[`alpha_op_${i}`] = McpCatalog.convertTool(
|
||||
{
|
||||
name: `op_${i}`,
|
||||
description: `${filler}${i}`,
|
||||
inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
|
||||
} as MCPToolDef,
|
||||
client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"],
|
||||
}
|
||||
} as any,
|
||||
client as any,
|
||||
)
|
||||
}
|
||||
tools["zeta_only_tool"] = mcpTool("only_tool", () => "", {
|
||||
type: "object",
|
||||
properties: { topic: { type: "string", description: "Subject to look up" } },
|
||||
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).toMatch(/- alpha \(150 tools, \d+ shown\)/)
|
||||
expect(description).toContain("- zeta (1 tool)\n")
|
||||
expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>")
|
||||
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(
|
||||
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
|
||||
)
|
||||
expect(description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.')
|
||||
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).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(
|
||||
tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx),
|
||||
)
|
||||
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[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
|
||||
expect(signature).toContain("tools.zeta.only_tool(input: {\n")
|
||||
expect(signature).toContain(" /** Subject to look up */\n topic: string")
|
||||
@@ -246,10 +290,7 @@ describe("code mode execute", () => {
|
||||
linear_search: mcpTool("search", () => ""),
|
||||
})
|
||||
const output = await Effect.runPromise(
|
||||
tool.execute(
|
||||
{ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" },
|
||||
ctx,
|
||||
),
|
||||
tool.execute({ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" }, ctx),
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
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 error = await failure(tool.execute({ code: "throw new Error('boom')" }, ctx))
|
||||
expect(error.message).toBe("Uncaught: boom")
|
||||
const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx))
|
||||
expect(output.output).toBe("Uncaught: boom")
|
||||
expect(output.metadata.error).toBe(true)
|
||||
})
|
||||
|
||||
test("reports an unknown tool as a failed execution", async () => {
|
||||
const tool = await build({ known_tool: mcpTool("tool", () => "ok") })
|
||||
const error = await failure(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
|
||||
expect(error.message).toContain("Unknown tool 'known.missing'")
|
||||
const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
|
||||
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 () => {
|
||||
@@ -371,6 +414,7 @@ describe("code mode execute", () => {
|
||||
|
||||
expect(output.output).toBe("denied: permission denied by user")
|
||||
expect(output.metadata.error).toBeUndefined()
|
||||
// The MCP tool itself never ran.
|
||||
expect(called).toEqual([])
|
||||
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" }] })),
|
||||
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
trigger,
|
||||
@@ -397,6 +442,7 @@ describe("code mode execute", () => {
|
||||
)
|
||||
|
||||
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([
|
||||
["tool.execute.before", "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!.output).toEqual({ args: { 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" }] })
|
||||
})
|
||||
|
||||
@@ -422,6 +469,7 @@ describe("code mode execute", () => {
|
||||
}
|
||||
const tool = await build(
|
||||
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
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.output).toBe("hook exploded / ok")
|
||||
// The before hook gates dispatch: the failed child's tool never executed.
|
||||
expect(called).toEqual(["b"])
|
||||
})
|
||||
|
||||
@@ -458,6 +508,7 @@ describe("code mode execute", () => {
|
||||
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({
|
||||
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))
|
||||
// The program received the structured content; the media rode along host-side.
|
||||
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.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" }])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const tool = await build({
|
||||
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.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("isolates the sandbox from host globals", async () => {
|
||||
const tool = await build({})
|
||||
const error = await failure(tool.execute({ code: "return process.env" }, ctx))
|
||||
expect(error.message).toContain("process")
|
||||
const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx))
|
||||
expect(output.metadata.error).toBe(true)
|
||||
})
|
||||
|
||||
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 tool = await build({
|
||||
host_trigger: mcpTool("trigger", () => {
|
||||
@@ -617,6 +613,8 @@ describe("code mode execute", () => {
|
||||
})
|
||||
|
||||
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 output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx))
|
||||
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")
|
||||
|
||||
const error = await failure(tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx))
|
||||
expect(error.message).toContain("Uncaught: boom")
|
||||
expect(error.message).toContain("Logs:\nbefore the throw")
|
||||
const err = await Effect.runPromise(
|
||||
tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx),
|
||||
)
|
||||
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_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).not.toContain("create_issue")
|
||||
expect(description).toContain("- github (1 tool)")
|
||||
@@ -659,7 +660,7 @@ describe("code mode permission visibility", () => {
|
||||
github_create_issue: mcpTool("create_issue", 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.list_issues(")
|
||||
expect(description).toContain("- github (2 tools)")
|
||||
@@ -675,16 +676,23 @@ describe("code mode permission visibility", () => {
|
||||
}),
|
||||
github_list_issues: mcpTool("list_issues", ok),
|
||||
},
|
||||
{},
|
||||
["github"],
|
||||
[deny("github_create_issue")],
|
||||
)
|
||||
|
||||
const denied = await failure(tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx))
|
||||
expect(denied.message).toContain("Unknown tool 'github.create_issue'")
|
||||
expect(denied.message).not.toContain("permission")
|
||||
const denied = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx),
|
||||
)
|
||||
expect(denied.metadata.error).toBe(true)
|
||||
expect(denied.output).toContain("Unknown tool 'github.create_issue'")
|
||||
expect(denied.output).not.toContain("permission")
|
||||
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.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 tool = await build(
|
||||
{ github_list_issues: mcpTool("list_issues", ok) },
|
||||
{},
|
||||
["github"],
|
||||
[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(asked).toEqual(["github_list_issues"])
|
||||
})
|
||||
@@ -707,8 +718,144 @@ describe("code mode permission visibility", () => {
|
||||
const visible = Permission.visibleTools(tools, [
|
||||
deny("a_tool"),
|
||||
askRule("b_tool"),
|
||||
// A scoped (non-"*") deny does not hide the tool from the catalog.
|
||||
{ permission: "c_tool", pattern: "something", action: "deny" },
|
||||
])
|
||||
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 { MessageID, SessionID } from "@/session/schema"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { MCP } from "@/mcp"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
@@ -57,6 +59,45 @@ const replacements = [
|
||||
const it = testEffect(LayerNode.compile(root, replacements))
|
||||
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 () => {
|
||||
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)")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -34,7 +34,6 @@ export const AISDK = Schema.Struct({
|
||||
export interface Native extends Schema.Schema.Type<typeof Native> {}
|
||||
export const Native = Schema.Struct({
|
||||
type: Schema.Literal("native"),
|
||||
package: Schema.String.pipe(optional),
|
||||
url: Schema.String.pipe(optional),
|
||||
settings: Schema.Record(Schema.String, Schema.Unknown),
|
||||
}).annotate({ identifier: "Provider.Native" })
|
||||
|
||||
@@ -4779,7 +4779,6 @@ export type ModelApi =
|
||||
| {
|
||||
id: string
|
||||
type: "native"
|
||||
package?: string
|
||||
url?: string
|
||||
settings: {
|
||||
[key: string]: unknown
|
||||
@@ -4854,7 +4853,6 @@ export type ProviderAisdk = {
|
||||
|
||||
export type ProviderNative = {
|
||||
type: "native"
|
||||
package?: string
|
||||
url?: string
|
||||
settings: {
|
||||
[key: string]: unknown
|
||||
|
||||
@@ -230,7 +230,6 @@
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
text-transform: capitalize;
|
||||
color: var(--task-agent-legacy-color, var(--text-strong));
|
||||
}
|
||||
|
||||
[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]) {
|
||||
[data-component="tool-trigger"] {
|
||||
[data-slot="basic-tool-tool-spinner"] {
|
||||
|
||||
@@ -62,7 +62,6 @@ import { animate } from "motion"
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { attached, inline, kind } from "./message-file"
|
||||
import { readPartText } from "./message-part-text"
|
||||
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
|
||||
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
@@ -371,44 +370,6 @@ const agentTones: Record<string, string> = {
|
||||
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 = [
|
||||
"var(--icon-agent-ask-base)",
|
||||
"var(--icon-agent-build-base)",
|
||||
@@ -433,31 +394,16 @@ function tone(name: string) {
|
||||
function taskAgent(
|
||||
raw: unknown,
|
||||
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 {}
|
||||
const key = raw.toLowerCase()
|
||||
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 {
|
||||
name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
|
||||
color,
|
||||
v2Color,
|
||||
border: v2Tone?.border ?? `color-mix(in srgb, ${v2Color} 48%, transparent)`,
|
||||
background: v2Tone?.background ?? `color-mix(in srgb, ${v2Color} 12%, transparent)`,
|
||||
color: item?.color ?? agentTones[key] ?? tone(key),
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (provider === "parallel") return "Parallel 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 title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
|
||||
const tone = createMemo(() => agent().color)
|
||||
const v2Tone = createMemo(() => agent().v2Color)
|
||||
const border = createMemo(() => agent().border)
|
||||
const background = createMemo(() => agent().background)
|
||||
const subtitle = createMemo(() => {
|
||||
const value =
|
||||
typeof props.input.description === "string" && props.input.description
|
||||
@@ -1978,27 +1921,17 @@ ToolRegistry.register({
|
||||
}
|
||||
|
||||
const trigger = () => (
|
||||
<div
|
||||
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-component="task-tool-card">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<Show when={running()}>
|
||||
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
|
||||
<Show when={newLayout()} fallback={<Spinner />}>
|
||||
<SessionProgressIndicatorV2
|
||||
style={{ color: v2Tone() ?? "light-dark(var(--v2-text-text-base), #ffffff)" }}
|
||||
/>
|
||||
</Show>
|
||||
<Spinner />
|
||||
</span>
|
||||
</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()}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
|
||||
</Show>
|
||||
|
||||
@@ -198,7 +198,7 @@ const en = {
|
||||
"model.knowledge": "Knowledge",
|
||||
"model.release": "Release",
|
||||
"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.noSummaryDescription": "This model has no OpenCode Go usage rows yet.",
|
||||
"model.tokens": "Tokens",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user