diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3aeef82d62fd..c6dfb0ebda4e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,3 @@ # web + desktop packages -packages/app/ @adamdotdevin -packages/tauri/ @adamdotdevin -packages/desktop/src-tauri/ @brendonovich -packages/desktop/ @adamdotdevin +packages/app/ @Hona @Brendonovich +packages/desktop/ @Hona @Brendonovich diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7498a84ae912..4c36f41106c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,7 +65,7 @@ jobs: - name: Run unit tests timeout-minutes: 20 - run: bun turbo test:ci --log-order=stream --log-prefix=task + run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} @@ -74,26 +74,6 @@ jobs: working-directory: packages/opencode run: bun run test:httpapi - - name: Publish unit reports - if: always() - uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 - with: - report_paths: packages/*/.artifacts/unit/junit.xml - check_name: "unit results (${{ matrix.settings.name }})" - detailed_summary: true - include_time_in_summary: true - fail_on_failure: false - - - name: Upload unit artifacts - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }} - include-hidden-files: true - if-no-files-found: ignore - retention-days: 7 - path: packages/*/.artifacts/unit/junit.xml - e2e: name: e2e (${{ matrix.settings.name }}) strategy: @@ -151,7 +131,6 @@ jobs: run: bun --cwd packages/app test:e2e:local env: CI: true - PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml timeout-minutes: 30 - name: Upload Playwright artifacts @@ -162,6 +141,5 @@ jobs: if-no-files-found: ignore retention-days: 7 path: | - packages/app/e2e/junit-*.xml packages/app/e2e/test-results packages/app/e2e/playwright-report diff --git a/bun.lock b/bun.lock index c6da10e34c3c..2987ba39dbd9 100644 --- a/bun.lock +++ b/bun.lock @@ -338,8 +338,8 @@ "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", - "electron-store": "^10", - "electron-updater": "^6", + "electron-store": "11.0.2", + "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", "marked": "^15", }, @@ -358,8 +358,8 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "@valibot/to-json-schema": "1.6.0", - "electron": "41.2.1", - "electron-builder": "^26", + "electron": "42.3.3", + "electron-builder": "26.15.0", "electron-vite": "^5", "solid-js": "catalog:", "sury": "11.0.0-alpha.4", @@ -528,7 +528,7 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/sdk": "1.27.1", + "@modelcontextprotocol/sdk": "1.29.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -570,6 +570,7 @@ "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", + "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", @@ -638,9 +639,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.3.2", - "@opentui/keymap": ">=0.3.2", - "@opentui/solid": ">=0.3.2", + "@opentui/core": ">=0.3.4", + "@opentui/keymap": ">=0.3.4", + "@opentui/solid": ">=0.3.4", }, "optionalPeers": [ "@opentui/core", @@ -920,6 +921,7 @@ "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { @@ -941,9 +943,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.3.2", - "@opentui/keymap": "0.3.2", - "@opentui/solid": "0.3.2", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1465,13 +1467,13 @@ "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], - "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], + "@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="], "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], "@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="], - "@electron/rebuild": ["@electron/rebuild@4.0.4", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg=="], + "@electron/rebuild": ["@electron/rebuild@4.0.3", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA=="], "@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="], @@ -1779,7 +1781,7 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="], @@ -1807,6 +1809,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1975,27 +1979,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.3.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.2", "@opentui/core-darwin-x64": "0.3.2", "@opentui/core-linux-arm64": "0.3.2", "@opentui/core-linux-arm64-musl": "0.3.2", "@opentui/core-linux-x64": "0.3.2", "@opentui/core-linux-x64-musl": "0.3.2", "@opentui/core-win32-arm64": "0.3.2", "@opentui/core-win32-x64": "0.3.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-5rCVS/3Obb3iLqg/egLCRArt7hAu3lX/9PWVHqUlnJylCT6b5NYDFljt0r3x8v3VG98LB71UzpnDv7DgmGKATw=="], + "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rFnGfqqEOGiUTbxglpiDA500KeRqcI1ukemhNfDrEzx3imAArS8mFZSuUG7ib31P5EpX+PXuvg0G9/3YXfgWeQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z+/GxKvB3NzMDSwuyWR7HDStbaNRf5a09lt5W9b4BGmCAFW/mbX0Tuh3kloubcMgiq5vLnVaNzY19hrIgJrjGQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-0TRuGNR+2GGk0rQuDaIxkIa/3Ty/XySfeOQLAUX4Jqaifky04As69fYT17yOhHqg5viCJUAGG/SdW8IH6C2osg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-noDKfwYUjutQUx5rtoyKrBIYaeSCmAmtxOSJdnKecyWEhMygtdHp4ssPtxzsZMuQAliHogCmD7vUG/pGMgcXMQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EZp+Lg9eZwzwNny4l2ACHdC95JbEKYbor1WImnm6IEo1e2Fgl9mltYv2J+i+Ea+dXQbrkK/MRIw7CRusxfqFFA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/FNGeJYhCHcIE3qBIo+nl8014NZ8u/XXUwQY2RjuWhMnzK9kQUfZ3cW4FP6FkOA/k4jGvByya47193II6djFtw=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-/zb5nCZKDgBS1UEQXrzTYUbbTPFMygbiZ/BfNWjEDIbm63gVzQ7pVouYbGP+88CRXIJtwT6LeoVPgp9nmOrDWQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-q8xqMhW1jlJVzos+A5+FXRquH01j1ZHmrNi/9++W1Ebz3LQYn+8Z8j7rcV/meIiDuo9nyRHigQQT+cy9xV4N2g=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], - "@opentui/keymap": ["@opentui/keymap@0.3.2", "", { "dependencies": { "@opentui/core": "0.3.2" }, "peerDependencies": { "@opentui/react": "0.3.2", "@opentui/solid": "0.3.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-y+IPBagxPvVrKIISmlfvO7ScvVIXGMmV92x0O+WzQ2vPf6fi5xgJc4YNmgEACfGdnM3ub14MkgEXqMOeUN7ZQA=="], + "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], - "@opentui/solid": ["@opentui/solid@0.3.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Yff0gSwIY/o0XeMciYeAUkQtea8bWzR0UjjVglmcBe13hWuJZt/GfjbDMdNNQ8zCrLubLEh04an5fYXCd7NMYQ=="], + "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2241,6 +2245,14 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.7.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg=="], + + "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="], "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], @@ -2483,7 +2495,7 @@ "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], "@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="], @@ -2997,7 +3009,7 @@ "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], - "app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], + "app-builder-lib": ["app-builder-lib@26.15.0", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.0", "electron-builder-squirrel-windows": "26.15.0" } }, "sha512-j2+P6Lh+l/VuWfXZWSs7u+OAPqYJQGnZZO30M833XQQaRuyohm4RZk7Gw4nQXfeyQH9GqXaTwR16Y0LaVTlS+g=="], "archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="], @@ -3025,6 +3037,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -3061,6 +3075,8 @@ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], "axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], @@ -3121,10 +3137,14 @@ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], "blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="], + "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], + "body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], @@ -3157,9 +3177,9 @@ "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], - "builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], + "builder-util": ["builder-util@26.15.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA=="], - "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], + "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], @@ -3171,6 +3191,8 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], + "c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -3235,6 +3257,8 @@ "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], + "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], @@ -3285,7 +3309,7 @@ "condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="], - "conf": ["conf@14.0.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^9.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.4.0" } }, "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw=="], + "conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], @@ -3307,7 +3331,7 @@ "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], - "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], @@ -3385,6 +3409,8 @@ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], @@ -3437,7 +3463,7 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], - "dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="], + "dmg-builder": ["dmg-builder@26.15.0", "", { "dependencies": { "app-builder-lib": "26.15.0", "builder-util": "26.15.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-oS8MWttbpIUF/2v8LOEY+f4ayL84ipMOarZvdRMl/pxlhLxAYjYMklTXHEXIl37Ig+qJv/bVF7HgyIoOoZyMWA=="], "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], @@ -3471,6 +3497,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -3483,9 +3511,9 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@41.2.1", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA=="], + "electron": ["electron@42.3.3", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ=="], - "electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="], + "electron-builder": ["electron-builder@26.15.0", "", { "dependencies": { "app-builder-lib": "26.15.0", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.0", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-zd4cfvjHmtyGqMaDudg5rAjNUkwIJDz8ICaCsz77hFKcjMQHcZNNNCs/C4phwN9+gEVwmhvpKMzNFum6fs/n6A=="], "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="], @@ -3497,13 +3525,13 @@ "electron-log": ["electron-log@5.4.4", "", {}, "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA=="], - "electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="], + "electron-publish": ["electron-publish@26.15.0", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-pt6K3ol/a+o3HbqmYkL2NYlVH5pd34tL4FPRcgX8E88xQAqQyIsseXe4vWy7Pq2BaYy+iFGJrtInZe11FFAQwQ=="], - "electron-store": ["electron-store@10.1.0", "", { "dependencies": { "conf": "^14.0.0", "type-fest": "^4.41.0" } }, "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w=="], + "electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="], "electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="], - "electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="], + "electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="], "electron-vite": ["electron-vite@5.0.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^6.7.14", "esbuild": "^0.25.11", "magic-string": "^0.30.19", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ=="], @@ -3519,6 +3547,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], @@ -3529,7 +3559,7 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], @@ -3923,6 +3953,8 @@ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], @@ -3997,6 +4029,8 @@ "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], @@ -4031,6 +4065,8 @@ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], @@ -4201,6 +4237,8 @@ "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], + "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "loglevelnext": ["loglevelnext@6.0.0", "", {}, "sha512-FDl1AI2sJGjHHG3XKJd6sG3/6ncgiGCQ0YkW46nxe7SfqQq6hujd9CvFXIXtkGBUN83KPZ2KSOJK8q5P0bSSRQ=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -4473,6 +4511,8 @@ "node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="], + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], @@ -4549,6 +4589,8 @@ "opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="], + "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="], @@ -4667,6 +4709,8 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + "pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], @@ -4751,6 +4795,10 @@ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -4897,6 +4945,8 @@ "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], + "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], @@ -5177,6 +5227,8 @@ "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], @@ -5347,6 +5399,10 @@ "unifont": ["unifont@0.5.2", "", { "dependencies": { "css-tree": "^3.0.0", "ofetch": "^1.4.1", "ohash": "^2.0.0" } }, "sha512-LzR4WUqzH9ILFvjLAUU7dK3Lnou/qd5kD+IakBtBK4S15/+x2y9VX+DcWQv6s551R6W+vzwgVS6tFg3XggGBgg=="], + "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], + + "unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="], + "unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="], "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], @@ -5385,6 +5441,8 @@ "unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="], + "unzipper": ["unzipper@0.12.3", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "^11.2.0", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA=="], + "upath": ["upath@1.2.0", "", {}, "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="], "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=="], @@ -5473,12 +5531,16 @@ "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="], @@ -5881,14 +5943,18 @@ "@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/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/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@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/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "@electron/rebuild/node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], + + "@electron/rebuild/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "@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=="], @@ -5929,6 +5995,8 @@ "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], @@ -6031,8 +6099,6 @@ "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], - "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], @@ -6173,6 +6239,10 @@ "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -6193,9 +6263,7 @@ "condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], - "conf/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], - - "conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -6203,12 +6271,12 @@ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -6217,6 +6285,8 @@ "dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], "editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -6227,16 +6297,24 @@ "electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "electron-builder-squirrel-windows/app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], + + "electron-builder-squirrel-windows/builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], + "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "electron-store/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -6273,6 +6351,8 @@ "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "got/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], "html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -6295,6 +6375,8 @@ "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], @@ -6319,6 +6401,8 @@ "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-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6343,6 +6427,12 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], @@ -6355,6 +6445,8 @@ "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], @@ -6381,6 +6473,10 @@ "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "rimraf/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=="], "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], @@ -6451,12 +6547,16 @@ "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], "venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], @@ -6619,10 +6719,22 @@ "@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "@electron/rebuild/node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "@electron/rebuild/node-gyp/make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="], + + "@electron/rebuild/node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + + "@electron/rebuild/node-gyp/proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + + "@electron/rebuild/node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + + "@electron/rebuild/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "@electron/rebuild/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -6891,6 +7003,8 @@ "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=="], @@ -6929,6 +7043,8 @@ "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "conf/dot-prop/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], @@ -6939,8 +7055,40 @@ "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "duplexer2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + "electron-builder-squirrel-windows/app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], + + "electron-builder-squirrel-windows/app-builder-lib/@electron/rebuild": ["@electron/rebuild@4.0.4", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg=="], + + "electron-builder-squirrel-windows/app-builder-lib/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], + + "electron-builder-squirrel-windows/app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + + "electron-builder-squirrel-windows/app-builder-lib/dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="], + + "electron-builder-squirrel-windows/app-builder-lib/electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="], + + "electron-builder-squirrel-windows/app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + + "electron-builder-squirrel-windows/app-builder-lib/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "electron-builder-squirrel-windows/app-builder-lib/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "electron-builder-squirrel-windows/app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + + "electron-builder-squirrel-windows/builder-util/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], + + "electron-builder-squirrel-windows/builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-builder-squirrel-windows/builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -6971,8 +7119,6 @@ "js-beautify/nopt/abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="], - "lazystream/readable-stream/core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - "lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -6983,6 +7129,8 @@ "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "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=="], @@ -6991,6 +7139,8 @@ "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + "restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -7017,6 +7167,8 @@ "unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -7153,6 +7305,26 @@ "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@electron/rebuild/node-gyp/make-fetch-happen/@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], + + "@electron/rebuild/node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], + + "@electron/rebuild/node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "@electron/rebuild/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@electron/rebuild/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@electron/rebuild/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@electron/rebuild/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@jsx-email/cli/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -7273,6 +7445,26 @@ "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=="], + + "electron-builder-squirrel-windows/app-builder-lib/dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "electron-builder-squirrel-windows/app-builder-lib/electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + + "electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "electron-builder-squirrel-windows/app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "electron-builder-squirrel-windows/app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "electron-builder-squirrel-windows/builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "electron-builder/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "electron-builder/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -7343,6 +7535,20 @@ "@aws-sdk/token-providers/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "@electron/rebuild/node-gyp/make-fetch-happen/@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch/minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], + + "@electron/rebuild/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@electron/rebuild/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -7359,6 +7565,8 @@ "babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "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=="], @@ -7379,6 +7587,14 @@ "tw-to-css/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch/minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "archiver-utils/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "archiver-utils/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -7386,5 +7602,19 @@ "js-beautify/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "js-beautify/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/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=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], } } diff --git a/infra/stats.ts b/infra/stats.ts index 67387ee5a86d..10a5fb20bca1 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -165,7 +165,7 @@ export const app = new sst.cloudflare.x.SolidStart("Stats", { domain: `stats.${domain}`, link: [database, EMAILOCTOPUS_API_KEY], environment: { - PUBLIC_URL: `https://${domain}/stats`, + PUBLIC_URL: `https://${domain}/data`, }, }) diff --git a/nix/hashes.json b/nix/hashes.json index c8bb9d1b5da5..b56d3d902123 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-yZeq16sWAtsAHZO3pbsr90t3+8PlOueRym2J/Kgpj1U=", - "aarch64-linux": "sha256-m54/gm6R8MyQXBh68K8McAfZ9nLLK08nYQwMvAsTs8o=", - "aarch64-darwin": "sha256-pbtcF4ZCUqKO/SVLxv4wskl+O1LlT2RNsUV5d4s9+WY=", - "x86_64-darwin": "sha256-o3ucdbFNy2y9Hrb594Y9AtpRBUPglhqZLtTyiqMECR8=" + "x86_64-linux": "sha256-3bbAjS5tWR8Jz/Y0lIsbOzRdqVqK8h1KOj7Zc27ogBo=", + "aarch64-linux": "sha256-1d26oPPA2oKNi9DHTUUpDEDtjpIVZFm27Oo3IfJdtS0=", + "aarch64-darwin": "sha256-kUHXPu4XAHqtBO/WAn7G8AnvaOoRA8u4d9pwy75pKtc=", + "x86_64-darwin": "sha256-f+CIeFDUGN3yZ9Pvsm/1zka/MGNKwV1ENkMAM6a02Bo=" } } diff --git a/package.json b/package.json index 62381c427ea7..61800b8fa74d 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.3.2", - "@opentui/keymap": "0.3.2", - "@opentui/solid": "0.3.2", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -140,7 +140,7 @@ "@types/node": "catalog:" }, "patchedDependencies": { - "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", diff --git a/packages/app/package.json b/packages/app/package.json index a51a051fc330..afeaffed94b9 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -19,8 +19,8 @@ "serve": "vite preview", "test": "bun run test:unit", "test:ci": "mkdir -p .artifacts/unit && bun test ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "test:unit": "bun test ./src", - "test:unit:watch": "bun test --watch ./src", + "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index e9fb1cfe4ed7..d9648a88ba67 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -7,12 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined -const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const - -if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) { - reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }]) -} - export default defineConfig({ testDir: "./e2e", outputDir: "./e2e/test-results", @@ -24,7 +18,7 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers, - reporter, + reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], webServer: { command, url: baseURL, diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index abb6439d8394..b231d3dc86b9 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -407,6 +407,7 @@ export function DialogSelectFile(props: { items={items} key={(item) => item.id} filterKeys={["title", "description", "category"]} + skipFilter={(item) => item.type === "file"} groupBy={grouped() ? (item) => item.category : () => ""} onMove={handleMove} onSelect={handleSelect} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index cc63c82450c0..08769068909c 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -509,11 +509,16 @@ export function useServerManagementController(options: { onSelect?: () => void; resetEdit() }) - async function handleRemove(url: ServerConnection.Key) { - tabs.removeServer(url) - server.remove(url) - if ((await platform.getDefaultServer?.()) === url) { - void platform.setDefaultServer?.(null) + async function handleRemove(key: ServerConnection.Key) { + try { + if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key) + tabs.removeServer(key) + server.remove(key) + if ((await platform.getDefaultServer?.()) === key) { + await setDefault(null) + } + } catch (err) { + showRequestError(language, err) } } diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx new file mode 100644 index 000000000000..73d623fc6a44 --- /dev/null +++ b/packages/app/src/components/help-button.tsx @@ -0,0 +1,54 @@ +import { Icon } from "@opencode-ai/ui/v2/icon" +import { Popover } from "@opencode-ai/ui/popover" +import { createSignal, Show } from "solid-js" +import { createStore } from "solid-js/store" + +export function HelpButton() { + if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null + + const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */ + const [shown, setShown] = createSignal(false) + + return ( + +
+ +
+
+ ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index b7b0a5163880..bdf55fee0564 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -658,6 +658,7 @@ export const PromptInput: Component = (props) => { }, key: atKey, filterKeys: ["display"], + skipFilter: (item) => item.type === "file" && !item.recent, groupBy: (item) => { if (item.type === "agent") return "agent" if (item.recent) return "recent" diff --git a/packages/app/src/components/prompt-input/server-attachment.test.ts b/packages/app/src/components/prompt-input/server-attachment.test.ts index b1fc95a10421..d4de9af0b802 100644 --- a/packages/app/src/components/prompt-input/server-attachment.test.ts +++ b/packages/app/src/components/prompt-input/server-attachment.test.ts @@ -3,7 +3,13 @@ import { serverAttachmentFile } from "./server-attachment" describe("serverAttachmentFile", () => { test("creates a file from server text content", async () => { - const file = serverAttachmentFile("docs/readme.txt", { type: "text", content: "hello", mime: "text/plain" }) + const file = serverAttachmentFile("docs/readme.txt", { + uri: "file:///docs/readme.txt", + name: "readme.txt", + content: "hello", + encoding: "utf8", + mime: "text/plain", + }) expect(file.name).toBe("readme.txt") expect(file.type).toBe("text/plain") @@ -12,7 +18,8 @@ describe("serverAttachmentFile", () => { test("creates a file from server base64 content", async () => { const file = serverAttachmentFile("images/pixel.png", { - type: "binary", + uri: "file:///images/pixel.png", + name: "pixel.png", content: "aGVsbG8=", encoding: "base64", mime: "image/png", diff --git a/packages/app/src/components/prompt-input/server-attachment.ts b/packages/app/src/components/prompt-input/server-attachment.ts index 94c63a989378..1bd7c1482462 100644 --- a/packages/app/src/components/prompt-input/server-attachment.ts +++ b/packages/app/src/components/prompt-input/server-attachment.ts @@ -1,8 +1,8 @@ import { getFilename } from "@opencode-ai/core/util/path" -import type { FileSystemBinaryContent, FileSystemTextContent } from "@opencode-ai/sdk/v2" +import type { FileSystemContent } from "@opencode-ai/sdk/v2" -export function serverAttachmentFile(path: string, data: FileSystemTextContent | FileSystemBinaryContent) { +export function serverAttachmentFile(path: string, data: FileSystemContent) { const content = - data.type === "text" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0)) + data.encoding === "utf8" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0)) return new File([content], getFilename(path), { type: data.mime }) } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 371cf87c5cbd..019f4f47089b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -447,6 +447,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { refreshTabsAreOverflowing() }) + if (tab.type !== "session") return null + return ( <> {i() !== 0 && ( diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index b0dbca221eed..325caae64c09 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,6 +1,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { checksum } from "@opencode-ai/core/util/encode" -import { useParams } from "@solidjs/router" +import { useParams, useSearchParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" @@ -153,9 +153,11 @@ const MAX_PROMPT_SESSIONS = 20 type PromptSession = ReturnType -type Scope = { - dir: string - id?: string +type Scope = { draftID: string } | { dir: string; id?: string } + +function scopeKey(scope: Scope) { + if ("draftID" in scope) return `draft:${scope.draftID}` + return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` } type PromptCacheEntry = { @@ -163,11 +165,15 @@ type PromptCacheEntry = { dispose: VoidFunction } -function createPromptSession(scope: ServerScope, dir: string, id: string | undefined) { - const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2` +function promptTarget(serverScope: ServerScope, scope: Scope) { + if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") + const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` + return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) +} +function createPromptSession(serverScope: ServerScope, scope: Scope) { const [store, setStore, _, ready] = persisted( - Persist.serverScoped(scope, dir, id, "prompt", [legacy]), + promptTarget(serverScope, scope), createStore<{ prompt: Prompt cursor?: number @@ -231,6 +237,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( gate: false, init: () => { const params = useParams() + const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK() const cache = new Map() @@ -254,8 +261,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const owner = getOwner() - const load = (dir: string, id: string | undefined) => { - const key = `${dir}:${id ?? WORKSPACE_KEY}` + const load = (scope: Scope) => { + const key = scopeKey(scope) const existing = cache.get(key) if (existing) { cache.delete(key) @@ -265,7 +272,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const entry = createRoot( (dispose) => ({ - value: createPromptSession(serverSDK.scope, dir, id), + value: createPromptSession(serverSDK.scope, scope), dispose, }), owner, @@ -276,8 +283,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( return entry.value } - const session = createMemo(() => load(params.dir!, params.id)) - const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session()) + const session = createMemo(() => + load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }), + ) + const pick = (scope?: Scope) => (scope ? load(scope) : session()) return { ready: () => session().ready, diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 17374983799c..cc43bac03bf5 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -2,10 +2,12 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { base64Encode } from "@opencode-ai/core/util/encode" import { createStore, produce } from "solid-js/store" -import { Persist, persisted } from "@/utils/persist" +import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { ServerConnection, useServer } from "./server" import { createEffect, startTransition } from "solid-js" import { useNavigate, useParams } from "@solidjs/router" +import { usePlatform } from "./platform" +import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" export type SessionTab = { @@ -15,10 +17,22 @@ export type SessionTab = { sessionId: string } -export type Tab = SessionTab +export type DraftTab = { + type: "draft" + draftID: string + server: ServerConnection.Key + directory: string + worktree?: string +} + +export type Tab = SessionTab | DraftTab -export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}` -export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}` +export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` + +export const tabHref = (tab: Tab) => + tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` + +export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { const dirBase64 = base64Encode(session.directory) @@ -33,6 +47,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ gate: false, init: () => { const server = useServer() + const platform = usePlatform() const fallback = server.key const [store, setStore, _, ready] = persisted( { @@ -53,6 +68,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const closing = new Set() + const removeDraftPersisted = (draftID: string) => { + for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) + } + createEffect(() => { if (!ready()) return const servers = new Set(server.list.map(ServerConnection.key)) @@ -83,10 +102,42 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }), ) }, + draft(draftID: string) { + const tab = store.find((item) => item.type === "draft" && item.draftID === draftID) + if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) + return tab + }, + newDraft(draft: Omit, prompt?: string) { + const draftID = uuid() + setStore( + produce((tabs) => { + tabs.push({ type: "draft", draftID, ...draft }) + }), + ) + navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) + }, + updateDraft(draftID: string, draft: Partial>) { + setStore( + (tab) => tab.type === "draft" && tab.draftID === draftID, + produce((tab) => Object.assign(tab, draft)), + ) + }, + promoteDraft(draftID: string, session: Omit) { + const active = `${location.pathname}${location.search}` === draftHref(draftID) + setStore( + produce((tabs) => { + const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) + if (index !== -1) tabs[index] = { type: "session", ...session } + }), + ) + if (active) navigateTab({ type: "session", ...session }) + removeDraftPersisted(draftID) + }, removeTab: (index: number) => { const tab = store[index] if (!tab) return const key = tabKey(tab) + const draftID = tab.type === "draft" ? tab.draftID : undefined const nextTab = store[index + 1] ?? store[index - 1] closing.add(key) void startTransition(() => { @@ -98,9 +149,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (nextTab) navigateTab(nextTab) else navigate("/") }).finally(() => closing.delete(key)) + if (draftID) removeDraftPersisted(draftID) }, removeServer(key: ServerConnection.Key) { + const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) + for (const draftID of drafts) removeDraftPersisted(draftID) if (server.key === key) navigate("/") }, removeSessions: (input: SessionTabsRemovedDetail) => { @@ -110,7 +164,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const sessionIDs = new Set(input.sessionIDs) const currentHref = params.dir && params.id - ? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id }) + ? tabHref({ + type: "session", + server: server.key, + dirBase64: params.dir, + sessionId: params.id, + }) : undefined const currentIndex = currentHref ? tabs.findIndex( diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 23ee508561c5..199db2fda66c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -63,6 +63,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { DebugBar } from "@/components/debug-bar" +import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { useDirectoryPicker } from "@/components/directory-picker" import { ServerConnection, useServer } from "@/context/server" @@ -2364,6 +2365,7 @@ export default function Layout(props: ParentProps) { {import.meta.env.DEV && } + } @@ -2517,6 +2519,7 @@ export default function Layout(props: ParentProps) { {import.meta.env.DEV && } + diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 336fefd5e192..cc80a27031bc 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -38,6 +38,7 @@ import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePrompt } from "@/context/prompt" +import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { useServerSDK } from "@/context/server-sdk" import { useSettings } from "@/context/settings" @@ -51,6 +52,7 @@ import { createSizing, focusTerminalById, shouldFocusTerminalOnKeyDown, + shouldShowFileTree, } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" @@ -194,6 +196,7 @@ export default function Page() { const sdk = useSDK() const serverSDK = useServerSDK() const settings = useSettings() + const platform = usePlatform() const prompt = usePrompt() const comments = useComments() const terminal = useTerminal() @@ -271,7 +274,16 @@ export default function Page() { const isV2NewSessionPage = () => shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id }) const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage()) - const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage()) + const desktopFileTreeOpen = createMemo( + () => + isDesktop() && + !isV2NewSessionPage() && + shouldShowFileTree({ + desktopV2: platform.platform === "desktop" && settings.general.newLayoutDesigns(), + showFileTree: settings.general.showFileTree(), + opened: layout.fileTree.opened(), + }), + ) const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" @@ -1745,80 +1757,84 @@ export default function Page() {
- - -
- {reviewContent({ - diffStyle: "unified", - classes: { - root: "pb-8", - header: "px-4", - container: "px-4", - }, - loadingClass: "px-4 py-4 text-text-weak", - emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", - })} -
-
- - - - !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() - } - centered={centered()} - setContentRef={(el) => { - content = el - autoScroll.contentRef(el) - - const root = scroller - if (root) scheduleScrollState(root) - }} - historyShift={historyLoader.shift()} - userMessages={historyLoader.userMessages()} - anchor={anchor} - setRevealMessage={(fn) => { - revealMessage = fn - }} - /> - - - - }> - {composerRegion("inline")} - - -
-
+
+ + +
+ {reviewContent({ + diffStyle: "unified", + classes: { + root: "pb-8", + header: "px-4", + container: "px-4", + }, + loadingClass: "px-4 py-4 text-text-weak", + emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", + })} +
+
+ + + + !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() + } + centered={centered()} + setContentRef={(el) => { + content = el + autoScroll.contentRef(el) + + const root = scroller + if (root) scheduleScrollState(root) + }} + historyShift={historyLoader.shift()} + userMessages={historyLoader.userMessages()} + anchor={anchor} + setRevealMessage={(fn) => { + revealMessage = fn + }} + /> + + + + }> + {composerRegion("inline")} + + +
+
- {composerRegion("dock")} + {composerRegion("dock")} +
size.start()}> diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 95f7cd384db8..1723410efbe8 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -8,8 +8,17 @@ import { focusTerminalById, getTabReorderIndex, shouldFocusTerminalOnKeyDown, + shouldShowFileTree, } from "./helpers" +describe("shouldShowFileTree", () => { + test("does not reserve space for a disabled v2 file tree", () => { + expect(shouldShowFileTree({ desktopV2: true, showFileTree: false, opened: true })).toBe(false) + expect(shouldShowFileTree({ desktopV2: false, showFileTree: false, opened: true })).toBe(true) + expect(shouldShowFileTree({ desktopV2: true, showFileTree: true, opened: true })).toBe(true) + }) +}) + describe("createOpenReviewFile", () => { test("opens and loads selected review file", () => { const calls: string[] = [] diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index e136ba9991bb..a1797e554d3d 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -20,6 +20,10 @@ type TabsInput = { export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}` +export function shouldShowFileTree(input: { desktopV2: boolean; showFileTree: boolean; opened: boolean }) { + return input.opened && (!input.desktopV2 || input.showFileTree) +} + export const createSessionTabs = (input: TabsInput) => { const review = input.review ?? (() => false) const hasReview = input.hasReview ?? (() => false) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index d5a3311917da..bff7c3e26f9a 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -24,7 +24,13 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" -import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" +import { + createOpenSessionFileTab, + createSessionTabs, + getTabReorderIndex, + shouldShowFileTree, + type Sizing, +} from "@/pages/session/helpers" import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" @@ -59,10 +65,18 @@ export function SessionSidePanel(props: { const isDesktop = createMediaQuery("(min-width: 768px)") const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns() - const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true)) + const shown = createMemo(() => !desktopV2() || settings.general.showFileTree()) const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened()) + const fileOpen = createMemo( + () => + isDesktop() && + shouldShowFileTree({ + desktopV2: desktopV2(), + showFileTree: settings.general.showFileTree(), + opened: layout.fileTree.opened(), + }), + ) const open = createMemo(() => reviewOpen() || fileOpen()) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 90a8d01dc89b..d8b822d856bb 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -166,6 +166,24 @@ describe("persist localStorage resilience", () => { expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull() }) + test("draft target isolates storage per draft and namespaces keys", () => { + const a = Persist.draft("draft-a", "prompt") + const b = Persist.draft("draft-b", "prompt") + + expect(a.key).toBe("draft:prompt") + expect(a.storage).not.toBe(b.storage) + expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage) + }) + + test("removes draft storage when removing persisted target", () => { + const target = Persist.draft("draft-a", "prompt") + storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') + + removePersisted(target) + + expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() + }) + test("server workspace target preserves local storage and isolates remote storage", () => { const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt") const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt") diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 590d19e96975..7c0fe28fbd5e 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -341,6 +341,12 @@ function workspaceStorage(dir: string) { return `opencode.workspace.${head}.${sum}.dat` } +function draftStorage(draftID: string) { + const head = (draftID.slice(0, 12) || "draft").replace(/[^a-zA-Z0-9._-]/g, "-") + const sum = checksum(draftID) ?? "0" + return `opencode.draft.${head}.${sum}.dat` +} + function legacyWorkspaceStorage(dir: string) { const storage = workspaceStorage(pathKey(dir)) const result = new Set() @@ -450,6 +456,12 @@ function localStorageDirect(): SyncStorage { } } +const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"] + +export function draftPersistedKeys() { + return DRAFT_PERSISTED_KEYS +} + export const PersistTesting = { localStorageDirect, localStorageWithPrefix, @@ -462,6 +474,9 @@ export const Persist = { global(key: string, legacy?: string[]): PersistTarget { return { storage: GLOBAL_STORAGE, key, legacy } }, + draft(draftID: string, key: string, legacy?: string[]): PersistTarget { + return { storage: draftStorage(draftID), key: `draft:${key}`, legacy } + }, serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget { if (scope === ServerScope.local) return Persist.global(key, legacy) return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) } diff --git a/packages/app/src/wsl/dialog-add-server.tsx b/packages/app/src/wsl/dialog-add-server.tsx index 5673d58b14b2..6c79824136fa 100644 --- a/packages/app/src/wsl/dialog-add-server.tsx +++ b/packages/app/src/wsl/dialog-add-server.tsx @@ -71,6 +71,17 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) { if (!distro) return null return current()?.opencodeChecks[distro] ?? null }) + const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart) + const distroReady = createMemo(() => { + const probe = selectedProbe() + if (!probe || !selectedDistro()) return false + if (selectedInstalled()?.version === 1) return false + return probe.canExecute && probe.hasBash && probe.hasCurl + }) + const opencodeReady = createMemo(() => { + const check = opencodeCheck() + return !!check?.resolvedPath && !check.error + }) const distroWarningProbe = createMemo(() => { const probe = selectedProbe() if (!probe) return null @@ -106,17 +117,6 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) { const job = current()?.job return job?.kind === "install-opencode" && job.distro === selectedDistro() }) - const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart) - const distroReady = createMemo(() => { - const probe = selectedProbe() - if (!probe || !selectedDistro()) return false - if (selectedInstalled()?.version === 1) return false - return probe.canExecute && probe.hasBash && probe.hasCurl - }) - const opencodeReady = createMemo(() => { - const check = opencodeCheck() - return !!check?.resolvedPath && !check.error - }) const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) const addDisabled = createMemo(() => { const job = current()?.job diff --git a/packages/app/src/wsl/settings.tsx b/packages/app/src/wsl/settings.tsx index 0a3d56fef35c..746a5861c52c 100644 --- a/packages/app/src/wsl/settings.tsx +++ b/packages/app/src/wsl/settings.tsx @@ -76,11 +76,7 @@ export function WslServerSettings(props: { })) const remove = (key: ServerConnection.Key) => { - if (!api) return - request.mutate(async () => { - await api.removeServer(key) - await props.controller.handleRemove(key) - }) + request.mutate(() => props.controller.handleRemove(key)) } return ( diff --git a/packages/console/app/src/lib/stats-proxy.ts b/packages/console/app/src/lib/stats-proxy.ts index c8c576ddcf35..399bf0efd88b 100644 --- a/packages/console/app/src/lib/stats-proxy.ts +++ b/packages/console/app/src/lib/stats-proxy.ts @@ -1,6 +1,8 @@ import type { APIEvent } from "@solidjs/start/server" import { Resource } from "@opencode-ai/console-resource" +const dataPath = "/data" + export async function statsProxy(evt: APIEvent) { const req = evt.request.clone() const targetUrl = new URL(req.url) @@ -8,8 +10,8 @@ export async function statsProxy(evt: APIEvent) { targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai" targetUrl.port = "" - if (targetUrl.pathname.startsWith("/stats/_build/") || targetUrl.pathname === "/stats/banner.png") { - targetUrl.pathname = targetUrl.pathname.slice("/stats".length) + if (targetUrl.pathname.startsWith(`${dataPath}/_build/`) || targetUrl.pathname === `${dataPath}/banner.jpg`) { + targetUrl.pathname = targetUrl.pathname.slice(dataPath.length) } const response = await fetch(targetUrl, { @@ -32,6 +34,17 @@ export async function statsProxy(evt: APIEvent) { }) } +export function statsRedirect(evt: APIEvent) { + const url = new URL(evt.request.url) + url.pathname = `${dataPath}${url.pathname.slice("/stats".length)}` + return new Response(null, { + status: 308, + headers: { + Location: url.toString(), + }, + }) +} + function rewriteStatsHtml(html: string) { - return html.replaceAll('"/_build/', '"/stats/_build/').replaceAll("'/_build/", "'/stats/_build/") + return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`) } diff --git a/packages/console/app/src/routes/data/[...path].ts b/packages/console/app/src/routes/data/[...path].ts new file mode 100644 index 000000000000..d1899215df0b --- /dev/null +++ b/packages/console/app/src/routes/data/[...path].ts @@ -0,0 +1,8 @@ +import { statsProxy } from "~/lib/stats-proxy" + +export const GET = statsProxy +export const POST = statsProxy +export const PUT = statsProxy +export const DELETE = statsProxy +export const OPTIONS = statsProxy +export const PATCH = statsProxy diff --git a/packages/console/app/src/routes/data/index.ts b/packages/console/app/src/routes/data/index.ts new file mode 100644 index 000000000000..d1899215df0b --- /dev/null +++ b/packages/console/app/src/routes/data/index.ts @@ -0,0 +1,8 @@ +import { statsProxy } from "~/lib/stats-proxy" + +export const GET = statsProxy +export const POST = statsProxy +export const PUT = statsProxy +export const DELETE = statsProxy +export const OPTIONS = statsProxy +export const PATCH = statsProxy diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index a4b1eb1bb78b..fee2dec3fb07 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -65,7 +65,7 @@ function LimitsGraph(props: { href: string }) { { id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" }, { id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" }, { id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" }, - { id: "minimax-m3", name: "MiniMax M3", req: 1400, d: "200ms" }, + { id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "200ms" }, { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "210ms" }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "220ms" }, { id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "230ms" }, diff --git a/packages/console/app/src/routes/legal/privacy-policy/index.tsx b/packages/console/app/src/routes/legal/privacy-policy/index.tsx index 42bb71aa3953..4426e0ddd08d 100644 --- a/packages/console/app/src/routes/legal/privacy-policy/index.tsx +++ b/packages/console/app/src/routes/legal/privacy-policy/index.tsx @@ -501,7 +501,7 @@ export default function PrivacyPolicy() { otherwise use the Services or send us any Personal Data. If we learn we have collected Personal Data from a child under 18 years of age, we will delete that information as quickly as possible. If you believe that a child under 18 years of age may have provided Personal Data to us, please contact us at{" "} - contact@anoma.ly. + help@anoma.ly.

California Resident Rights

@@ -520,7 +520,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a California resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following - rights apply to you, please contact us at contact@anoma.ly. + rights apply to you, please contact us at help@anoma.ly.

Access

@@ -605,7 +605,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a Colorado resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following - rights apply to you, please contact us at contact@anoma.ly. + rights apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -676,7 +676,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a Connecticut resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following - rights apply to you, please contact us at contact@anoma.ly. + rights apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -745,7 +745,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a Delaware resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following - rights apply to you, please contact us at contact@anoma.ly. + rights apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -818,7 +818,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are an Iowa resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following rights - apply to you, please contact us at contact@anoma.ly. + apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -864,7 +864,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a Montana resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following rights - apply to you, please contact us at contact@anoma.ly. + apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -937,7 +937,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a Nebraska resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following - rights apply to you, please contact us at contact@anoma.ly. + rights apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -1007,7 +1007,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a New Hampshire resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following - rights apply to you, please contact us at contact@anoma.ly. + rights apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -1078,7 +1078,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a New Jersey resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following - rights apply to you, please contact us at contact@anoma.ly. + rights apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -1151,7 +1151,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are an Oregon resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following rights - apply to you, please contact us at contact@anoma.ly. + apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -1225,7 +1225,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a Texas resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following rights - apply to you, please contact us at contact@anoma.ly. + apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -1293,7 +1293,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a Utah resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following rights apply - to you, please contact us at contact@anoma.ly. + to you, please contact us at help@anoma.ly.

Access and Portability

@@ -1339,7 +1339,7 @@ export default function PrivacyPolicy() { If there are any conflicts between this section and any other provision of this Privacy Policy and you are a Virginia resident, the portion that is more protective of Personal Data shall control to the extent of such conflict. If you have any questions about this section or whether any of the following - rights apply to you, please contact us at contact@anoma.ly. + rights apply to you, please contact us at help@anoma.ly.

Access and Portability

@@ -1418,7 +1418,7 @@ export default function PrivacyPolicy() {

@@ -1430,7 +1430,7 @@ export default function PrivacyPolicy() {

@@ -1457,7 +1457,7 @@ export default function PrivacyPolicy() {

@@ -1474,8 +1474,8 @@ export default function PrivacyPolicy() {

Under California Civil Code Sections 1798.83-1798.84, California residents are entitled to contact us to prevent disclosure of Personal Data to third parties for such third parties' direct marketing purposes; - in order to submit such a request, please contact us at{" "} - contact@anoma.ly. + in order to submit such a request, please contact us at help@anoma.ly + .

@@ -1500,7 +1500,7 @@ export default function PrivacyPolicy() {

  • - Email: contact@anoma.ly + Email: help@anoma.ly
  • Phone: +1 415 794-0209
  • Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States
  • diff --git a/packages/console/app/src/routes/legal/terms-of-service/index.tsx b/packages/console/app/src/routes/legal/terms-of-service/index.tsx index 55a9fd42f111..7847c44bdc83 100644 --- a/packages/console/app/src/routes/legal/terms-of-service/index.tsx +++ b/packages/console/app/src/routes/legal/terms-of-service/index.tsx @@ -30,7 +30,7 @@ export default function TermsOfService() {

    - Email: contact@anoma.ly + Email: help@anoma.ly

    @@ -114,7 +114,7 @@ export default function TermsOfService() { attempt to register for or otherwise use the Services or send us any personal information. If we learn we have collected personal information from a child under 13 years of age, we will delete that information as quickly as possible. If you believe that a child under 13 years of age may have provided - us personal information, please contact us at contact@anoma.ly. + us personal information, please contact us at help@anoma.ly.

    What are the basics of using OpenCode?

    @@ -315,7 +315,7 @@ export default function TermsOfService() { specified time of the trial. You must stop using a Paid Service before the end of the trial period in order to avoid being charged for that Paid Service. If you cancel prior to the end of the trial period and are inadvertently charged for a Paid Service, please contact us at{" "} - contact@anoma.ly. + help@anoma.ly.

    What if I want to stop using the Services?

    diff --git a/packages/console/app/src/routes/stats/[...path].ts b/packages/console/app/src/routes/stats/[...path].ts index d1899215df0b..4a387c039ca5 100644 --- a/packages/console/app/src/routes/stats/[...path].ts +++ b/packages/console/app/src/routes/stats/[...path].ts @@ -1,8 +1,8 @@ -import { statsProxy } from "~/lib/stats-proxy" +import { statsRedirect } from "~/lib/stats-proxy" -export const GET = statsProxy -export const POST = statsProxy -export const PUT = statsProxy -export const DELETE = statsProxy -export const OPTIONS = statsProxy -export const PATCH = statsProxy +export const GET = statsRedirect +export const POST = statsRedirect +export const PUT = statsRedirect +export const DELETE = statsRedirect +export const OPTIONS = statsRedirect +export const PATCH = statsRedirect diff --git a/packages/console/app/src/routes/stats/index.ts b/packages/console/app/src/routes/stats/index.ts index d1899215df0b..4a387c039ca5 100644 --- a/packages/console/app/src/routes/stats/index.ts +++ b/packages/console/app/src/routes/stats/index.ts @@ -1,8 +1,8 @@ -import { statsProxy } from "~/lib/stats-proxy" +import { statsRedirect } from "~/lib/stats-proxy" -export const GET = statsProxy -export const POST = statsProxy -export const PUT = statsProxy -export const DELETE = statsProxy -export const OPTIONS = statsProxy -export const PATCH = statsProxy +export const GET = statsRedirect +export const POST = statsRedirect +export const PUT = statsRedirect +export const DELETE = statsRedirect +export const OPTIONS = statsRedirect +export const PATCH = statsRedirect diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index dfff3bd809b4..e1e4e6cbd39f 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -226,7 +226,7 @@ export async function POST(input: APIEvent) { expand: ["discounts", "payments"], }) const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string - const couponID = (invoice.discounts[0] as Stripe.Discount).coupon?.id as string + const couponID = (invoice.discounts[0] as Stripe.Discount)?.coupon?.id as string if (!paymentID) { // payment id can be undefined when using coupon if (!couponID) throw new Error("Payment ID not found") diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx index 4d9b0cabd547..90280635ff7e 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx @@ -143,7 +143,7 @@ export function BillingSection() {

    {i18n.t("workspace.billing.title")}

    {i18n.t("workspace.billing.subtitle.beforeLink")}{" "} - {i18n.t("workspace.billing.contactUs")}{" "} + {i18n.t("workspace.billing.contactUs")}{" "} {i18n.t("workspace.billing.subtitle.afterLink")}

diff --git a/packages/core/package.json b/packages/core/package.json index e720d9357c0d..482d66bb648d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,8 +9,7 @@ "db": "bun drizzle-kit", "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", - "test": "bun test", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun test --only-failures", "typecheck": "tsgo --noEmit" }, "bin": { diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts new file mode 100644 index 000000000000..81e0804b4e9e --- /dev/null +++ b/packages/core/src/config/plugin/reference.ts @@ -0,0 +1,65 @@ +export * as ConfigReferencePlugin from "./reference" + +import path from "path" +import { Effect } from "effect" +import { Config } from "../../config" +import { ConfigReference } from "../reference" +import { Global } from "../../global" +import { Location } from "../../location" +import { PluginV2 } from "../../plugin" +import { Reference } from "../../reference" +import { AbsolutePath } from "../../schema" + +export const Plugin = { + id: PluginV2.ID.make("core/config-reference"), + effect: Effect.gen(function* () { + const config = yield* Config.Service + const global = yield* Global.Service + const location = yield* Location.Service + const references = yield* Reference.Service + const update = yield* references.transform() + const entries = new Map() + for (const doc of (yield* config.entries()).filter( + (entry): entry is Config.Document => entry.type === "document", + )) { + const directory = doc.path ? path.dirname(doc.path) : location.directory + for (const [name, entry] of Object.entries(doc.info.references ?? {})) { + if (!validAlias(name)) continue + entries.set( + name, + local(entry) + ? new Reference.LocalSource({ + type: "local", + path: AbsolutePath.make( + localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), + ), + }) + : new Reference.GitSource({ + type: "git", + repository: typeof entry === "string" ? entry : entry.repository, + branch: typeof entry === "string" ? undefined : entry.branch, + }), + ) + } + } + + yield* update((editor) => { + for (const [name, source] of entries) editor.add(name, source) + }) + }), +} + +function validAlias(name: string) { + return name.length > 0 && !/[\/\s`,]/.test(name) +} + +function local(entry: ConfigReference.Entry): entry is string | ConfigReference.Local { + return typeof entry === "string" + ? entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~") + : "path" in entry +} + +function localPath(directory: string, home: string, value: string) { + if (value.startsWith("~/")) return path.join(home, value.slice(2)) + return path.isAbsolute(value) ? value : path.resolve(directory, value) +} diff --git a/packages/core/src/config/reference.ts b/packages/core/src/config/reference.ts index fbd6c840da60..040169855f80 100644 --- a/packages/core/src/config/reference.ts +++ b/packages/core/src/config/reference.ts @@ -16,33 +16,3 @@ export type Entry = typeof Entry.Type export const Info = Schema.Record(Schema.String, Entry) export type Info = typeof Info.Type - -export type NormalizedEntry = - | { readonly kind: "local"; readonly path: string } - | { readonly kind: "git"; readonly repository: string; readonly branch?: string } - | { readonly kind: "invalid"; readonly message: string } - -export type NormalizedInfo = Record - -export function validateAlias(name: string) { - if (name.length === 0) return "Reference alias must not be empty" - if (/[\/\s`,]/.test(name)) return "Reference alias must not contain /, whitespace, comma, or backtick" -} - -export function normalizeEntry(entry: Entry): NormalizedEntry { - if (typeof entry === "string") { - if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) return { kind: "local", path: entry } - return { kind: "git", repository: entry } - } - if ("path" in entry) return { kind: "local", path: entry.path } - return { kind: "git", repository: entry.repository, branch: entry.branch } -} - -export function normalize(info: Info): NormalizedInfo { - return Object.fromEntries( - Object.entries(info).map(([name, entry]) => { - const message = validateAlias(name) - return [name, message ? { kind: "invalid" as const, message } : normalizeEntry(entry)] - }), - ) -} diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index ad8d4126d454..d6e0f9f95d69 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -24,6 +24,8 @@ import { import * as NodeChildProcess from "node:child_process" import { PassThrough } from "node:stream" import launch from "cross-spawn" +import { LayerNode } from "./effect/layer-node" +import { filesystem, path } from "./effect/layer-node-platform" const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) @@ -501,5 +503,6 @@ export const layer: Layer.Layer @@ -58,3 +59,5 @@ export const defaultLayer = Layer.unwrap( return layerFromPath(path()) }), ).pipe(Layer.provide(Global.defaultLayer)) + +export const node = LayerNode.make(layerFromPath(path()), []) diff --git a/packages/core/src/effect/layer-node-platform.ts b/packages/core/src/effect/layer-node-platform.ts new file mode 100644 index 000000000000..2e63d2958257 --- /dev/null +++ b/packages/core/src/effect/layer-node-platform.ts @@ -0,0 +1,12 @@ +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import { FetchHttpClient } from "effect/unstable/http" +import { LayerNode } from "./layer-node" + +export const filesystem = LayerNode.make(NodeFileSystem.layer, []) +export const path = LayerNode.make(NodePath.layer, []) +export const httpClient = LayerNode.make(FetchHttpClient.layer, []) +export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient]) +export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor]) + +export * as LayerNodePlatform from "./layer-node-platform" diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts new file mode 100644 index 000000000000..e2873336070c --- /dev/null +++ b/packages/core/src/effect/layer-node.ts @@ -0,0 +1,95 @@ +import { Layer } from "effect" + +type RuntimeLayer = Layer.Layer +type AnyNode = Node +type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]] +type Output = [Item] extends [never] ? never : Item extends Node ? A : never +type Error = [Item] extends [never] ? never : Item extends Node ? E : never +type Missing = Exclude> +type CheckDependencies = [ + Missing, Dependencies>, +] extends [never] + ? unknown + : { readonly "Missing dependencies": Missing, Dependencies> } +declare const $OutputType: unique symbol +declare const $ErrorType: unique symbol + +export type Node = { + readonly kind: "layer" | "group" + readonly implementation?: Layer.Any + readonly dependencies: readonly AnyNode[] + readonly [$OutputType]?: () => A + readonly [$ErrorType]?: () => E +} + +export function make( + implementation: Implementation, + dependencies: Items & CheckDependencies>, +): Node, Layer.Error | Error> { + return { kind: "layer", implementation: implementation as Layer.Any, dependencies } +} + +export function group( + dependencies: Items, +): Node, Error> { + return { kind: "group", dependencies } +} + +export type Replacement = { + readonly source: Node + readonly replacement: Node +} + +type CheckReplacementErrors = [Exclude] extends [never] + ? unknown + : { readonly "New replacement errors": Exclude } + +export function replace( + source: Node, + replacement: Node, E2> & CheckReplacementErrors>, +): Replacement { + return { source, replacement } +} + +export function buildLayer(node: Node, options?: { readonly replacements?: readonly Replacement[] }) { + const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement])) + const cache = new Map() + const visiting = new Set() + const stack: AnyNode[] = [] + const ids = new Map() + + const visit = (input: AnyNode): RuntimeLayer => { + const node = replacements.get(input) ?? input + const cached = cache.get(node) + if (cached) return cached + if (visiting.has(node)) { + const start = stack.indexOf(node) + const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ") + throw new Error(`Cycle detected in app graph: ${cycle}`) + } + if (!ids.has(node)) ids.set(node, ids.size + 1) + visiting.add(node) + stack.push(node) + try { + const dependencies = node.dependencies.map(visit) + const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]] + const result = + node.kind === "group" + ? dependencies.length === 0 + ? Layer.empty + : Layer.mergeAll(...nonEmpty) + : dependencies.length === 0 + ? (node.implementation as RuntimeLayer) + : Layer.provide(node.implementation as RuntimeLayer, nonEmpty) + cache.set(node, result) + return result + } finally { + stack.pop() + visiting.delete(node) + } + } + + return visit(node) as unknown as Layer.Layer +} + +export * as LayerNode from "./layer-node" diff --git a/packages/core/src/effect/logger.ts b/packages/core/src/effect/logger.ts deleted file mode 100644 index 69f9631e06bf..000000000000 --- a/packages/core/src/effect/logger.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Cause, Effect, Logger, References } from "effect" -import * as Log from "../util/log" - -type Fields = Record - -const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key) - -export interface Handle { - readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly with: (extra: Fields) => Handle -} - -const clean = (input?: Fields): Fields => - Object.fromEntries( - Object.entries(input ?? {}) - .filter((entry) => entry[1] !== undefined && entry[1] !== null) - .map(([key, value]) => [normalizeKey(key), value]), - ) - -const text = (input: unknown): string => { - // oxlint-disable-next-line no-base-to-string - if (Array.isArray(input)) return input.map((item) => String(item)).join(" ") - // oxlint-disable-next-line no-base-to-string - return input === undefined ? "" : String(input) -} - -const call = (run: (msg?: unknown) => Effect.Effect, base: Fields, msg?: unknown, extra?: Fields) => { - const ann = clean({ ...base, ...extra }) - const fx = run(msg) - return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx -} - -export const logger = Logger.make((opts) => { - const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations)) - const now = opts.date.getTime() - for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) { - extra[`logSpan.${key}`] = `${now - start}ms` - } - if (opts.cause.reasons.length > 0) { - extra.cause = Cause.pretty(opts.cause) - } - - const svc = typeof extra.service === "string" ? extra.service : undefined - if (svc) delete extra.service - const log = svc ? Log.create({ service: svc }) : Log.Default - const msg = text(opts.message) - - switch (opts.logLevel) { - case "Trace": - case "Debug": - return log.debug(msg, extra) - case "Warn": - return log.warn(msg, extra) - case "Error": - case "Fatal": - return log.error(msg, extra) - default: - return log.info(msg, extra) - } -}) - -export const layer = Logger.layer([logger], { mergeWithExisting: false }) - -export const create = (base: Fields = {}): Handle => ({ - debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra), - info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra), - warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra), - error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra), - with: (extra) => create({ ...base, ...extra }), -}) diff --git a/packages/core/src/effect/observability.ts b/packages/core/src/effect/observability.ts deleted file mode 100644 index 0203079abe1e..000000000000 --- a/packages/core/src/effect/observability.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Effect, Layer, Logger } from "effect" -import { FetchHttpClient } from "effect/unstable/http" -import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" -import * as EffectLogger from "./logger" -import { Flag } from "../flag/flag" -import { InstallationChannel, InstallationVersion } from "../installation/version" -import { ensureProcessMetadata } from "../util/opencode-process" - -const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT -export const enabled = !!base -const processID = crypto.randomUUID() - -const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS - ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( - (acc, x) => { - const [key, ...value] = x.split("=") - acc[key] = value.join("=") - return acc - }, - {} as Record, - ) - : undefined - -export function resource(): { serviceName: string; serviceVersion: string; attributes: Record } { - const processMetadata = ensureProcessMetadata("main") - const attributes: Record = (() => { - const value = process.env.OTEL_RESOURCE_ATTRIBUTES - if (!value) return {} - try { - return Object.fromEntries( - value.split(",").map((entry) => { - const index = entry.indexOf("=") - if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry") - return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))] - }), - ) - } catch { - return {} - } - })() - - return { - serviceName: "opencode", - serviceVersion: InstallationVersion, - attributes: { - ...attributes, - "deployment.environment.name": InstallationChannel, - "opencode.client": Flag.OPENCODE_CLIENT, - "opencode.process_role": processMetadata.processRole, - "opencode.run_id": processMetadata.runID, - "service.instance.id": processID, - }, - } -} - -function logs() { - return Logger.layer( - [ - EffectLogger.logger, - OtlpLogger.make({ - url: `${base}/v1/logs`, - resource: resource(), - headers, - }), - ], - { mergeWithExisting: false }, - ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)) -} - -const traces = async () => { - const NodeSdk = await import("@effect/opentelemetry/NodeSdk") - const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") - const SdkBase = await import("@opentelemetry/sdk-trace-base") - - // @effect/opentelemetry creates a NodeTracerProvider but never calls - // register(), so the global @opentelemetry/api context manager stays - // as the no-op default. Non-Effect code (like the AI SDK) that calls - // tracer.startActiveSpan() relies on context.active() to find the - // parent span - without a real context manager every span starts a - // new trace. Registering AsyncLocalStorageContextManager fixes this. - const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks") - const { context } = await import("@opentelemetry/api") - const mgr = new AsyncLocalStorageContextManager() - mgr.enable() - context.setGlobalContextManager(mgr) - - return NodeSdk.layer(() => ({ - resource: resource(), - spanProcessor: new SdkBase.BatchSpanProcessor( - new OTLP.OTLPTraceExporter({ - url: `${base}/v1/traces`, - headers, - }), - ), - })) -} - -export const layer = !base - ? EffectLogger.layer - : Layer.unwrap( - Effect.gen(function* () { - const trace = yield* Effect.promise(traces) - return Layer.mergeAll(trace, logs()) - }), - ) - -export const Observability = { enabled, layer } diff --git a/packages/core/src/effect/runtime.ts b/packages/core/src/effect/runtime.ts index e4f682709897..6ad0f85176ca 100644 --- a/packages/core/src/effect/runtime.ts +++ b/packages/core/src/effect/runtime.ts @@ -1,6 +1,6 @@ import { Layer, type Context, ManagedRuntime, type Effect } from "effect" import { memoMap } from "./memo-map" -import { Observability } from "./observability" +import { Observability } from "../observability" export function makeRuntime(service: Context.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 0ad0714e98df..7a33eedc40f5 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -7,6 +7,7 @@ import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" import { Identifier } from "./util/identifier" +import { LayerNode } from "./effect/layer-node" import { isDeepStrictEqual } from "node:util" export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( @@ -410,9 +411,7 @@ export const layerWith = (options?: LayerOptions) => Effect.catchCauseIf( (cause) => !Cause.hasInterrupts(cause), (cause) => - Effect.logError("Event observer failed").pipe( - Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }), - ), + Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }), ), ) @@ -676,5 +675,6 @@ export const layerWith = (options?: LayerOptions) => ) export const layer = layerWith() +export const node = LayerNode.make(layer, [Database.node]) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 3c08c8135f62..8ef220a187c0 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -2,166 +2,32 @@ export * as FileSystem from "./filesystem" import path from "path" import { pathToFileURL } from "url" -import fuzzysort from "fuzzysort" -import ignore from "ignore" -import { Context, Effect, Layer, Option, Schema, Stream } from "effect" +import { Context, Effect, Layer, Option, Schema } from "effect" import { EventV2 } from "./event" import { FSUtil } from "./fs-util" -import { Global } from "./global" import { Location } from "./location" -import { ProjectReference } from "./project-reference" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" -import { Protected } from "./filesystem/protected" -import { Ripgrep } from "./filesystem/ripgrep" -import { ToolOutputStore } from "./tool-output-store" +import { Search } from "./filesystem/search" export const ReadInput = Schema.Struct({ - path: Schema.String, - reference: Schema.NonEmptyString.pipe(Schema.optional), + path: RelativePath, }) export type ReadInput = typeof ReadInput.Type -export const MAX_READ_LINES = 2_000 -export const MAX_READ_BYTES = 50 * 1024 -export const READ_SAMPLE_BYTES = 4 * 1024 -export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024 -const MAX_LINE_LENGTH = 2_000 -const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` - -export class BinaryFileError extends Error { - constructor(readonly resource: string) { - super(`Cannot read binary file: ${resource}`) - this.name = "BinaryFileError" - } -} - -const BINARY_EXTENSIONS = new Set([ - ".zip", - ".tar", - ".gz", - ".exe", - ".dll", - ".so", - ".class", - ".jar", - ".war", - ".7z", - ".doc", - ".docx", - ".xls", - ".xlsx", - ".ppt", - ".pptx", - ".odt", - ".ods", - ".odp", - ".bin", - ".dat", - ".obj", - ".o", - ".a", - ".lib", - ".wasm", - ".pyc", - ".pyo", -]) - -export const isBinary = (resource: string, bytes: Uint8Array) => { - if (BINARY_EXTENSIONS.has(path.extname(resource).toLowerCase())) return true - if (bytes.length === 0) return false - let nonPrintable = 0 - for (const byte of bytes) { - if (byte === 0) return true - if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++ - } - return nonPrintable / bytes.length > 0.3 -} - -const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) -const supportedImageMime = (bytes: Uint8Array) => { - if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" - if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" - if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" - if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) - return "image/webp" -} - -export class MediaIngestLimitError extends Error { - constructor( - readonly resource: string, - readonly maximumBytes: number, - ) { - super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`) - this.name = "MediaIngestLimitError" - } -} - -export class TextContent extends Schema.Class("FileSystem.TextContent")({ - type: Schema.Literal("text"), - content: Schema.String, - mime: Schema.String, -}) {} - -export class BinaryContent extends Schema.Class("FileSystem.BinaryContent")({ - type: Schema.Literal("binary"), +export const Content = Schema.Struct({ + uri: Schema.String, + name: Schema.String.pipe(Schema.optional), content: Schema.String, - encoding: Schema.Literal("base64"), + encoding: Schema.Literals(["utf8", "base64"]), mime: Schema.String, -}) {} - -export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type")) +}).annotate({ identifier: "FileSystem.Content" }) export type Content = typeof Content.Type -export const TextPageInput = Schema.Struct({ - offset: PositiveInt.pipe(Schema.optional), - limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), -}) -export type TextPageInput = typeof TextPageInput.Type - -export class TextPage extends Schema.Class("FileSystem.TextPage")({ - type: Schema.Literal("text-page"), - content: Schema.String, - mime: Schema.String, - offset: PositiveInt, - truncated: Schema.Boolean, - next: PositiveInt.pipe(Schema.optional), -}) {} - -export class ReadPath extends Schema.Class("FileSystem.ReadPath")({ - type: Schema.Literals(["file", "directory"]), - resource: Schema.String, -}) {} - export const ListInput = Schema.Struct({ - path: Schema.String.pipe(Schema.optional), - reference: Schema.NonEmptyString.pipe(Schema.optional), + path: RelativePath.pipe(Schema.optional), }) export type ListInput = typeof ListInput.Type -export const ListPageInput = Schema.Struct({ - ...ListInput.fields, - offset: PositiveInt.pipe(Schema.optional), - limit: PositiveInt.check(Schema.isLessThanOrEqualTo(2_000)).pipe(Schema.optional), -}) -export type ListPageInput = typeof ListPageInput.Type - -export class ListTarget extends Schema.Class("FileSystem.ListTarget")({ - absolute: Schema.String, - real: Schema.String, - directory: Schema.String, - root: Schema.String, - resource: Schema.String, -}) {} - -/** Canonical root and permission resource for Location-scoped search. */ -export class RootTarget extends Schema.Class("FileSystem.RootTarget")({ - real: Schema.String, - root: Schema.String, - resource: Schema.String, - reference: Schema.NonEmptyString.pipe(Schema.optional), - type: Schema.Literals(["file", "directory"]), -}) {} - export class Entry extends Schema.Class("FileSystem.Entry")({ path: RelativePath, uri: Schema.String, @@ -169,12 +35,6 @@ export class Entry extends Schema.Class("FileSystem.Entry")({ mime: Schema.String, }) {} -export class ListPage extends Schema.Class("FileSystem.ListPage")({ - entries: Schema.Array(Entry), - truncated: Schema.Boolean, - next: PositiveInt.pipe(Schema.optional), -}) {} - export const FindInput = Schema.Struct({ query: Schema.String, type: Schema.Literals(["file", "directory"]).pipe(Schema.optional), @@ -189,7 +49,7 @@ export const GrepInput = Schema.Struct({ }) export type GrepInput = typeof GrepInput.Type -export class GrepMatch extends Schema.Class("FileSystem.GrepMatch")({ +export class GrepMatch extends Schema.Class("LocationFileSystem.GrepMatch")({ path: RelativePath, lines: Schema.String, line: PositiveInt, @@ -214,21 +74,9 @@ export const Event = { export interface Interface { readonly read: (input: ReadInput) => Effect.Effect - readonly resolveReadPath: (input: ReadInput) => Effect.Effect - readonly readTool: (input: ReadInput, page?: TextPageInput) => Effect.Effect readonly list: (input?: ListInput) => Effect.Effect - /** Resolve a contained canonical search root and its permission resource. */ - readonly resolveRoot: (input?: ListInput) => Effect.Effect - readonly resolveList: (input?: ListInput) => Effect.Effect - readonly listResolved: (target: ListTarget) => Effect.Effect - readonly listPage: (input?: ListPageInput) => Effect.Effect - readonly listPageResolved: ( - target: ListTarget, - page?: Pick, - ) => Effect.Effect readonly find: (input: FindInput) => Effect.Effect readonly grep: (input: GrepInput) => Effect.Effect - readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean } export class Service extends Context.Service()("@opencode/v2/FileSystem") {} @@ -238,57 +86,22 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FSUtil.Service const location = yield* Location.Service - const global = yield* Effect.serviceOption(Global.Service) - const references = yield* ProjectReference.Service - const ripgrep = yield* Ripgrep.Service + const search = yield* Search.Service const root = yield* fs.realPath(location.directory).pipe(Effect.orDie) - const ignored = ignore() - const gitignore = yield* fs - .readFileString(path.join(location.project.directory, ".gitignore")) - .pipe(Effect.catch(() => Effect.succeed(""))) - if (gitignore) ignored.add(gitignore) - const ignorefile = yield* fs - .readFileString(path.join(location.project.directory, ".ignore")) - .pipe(Effect.catch(() => Effect.succeed(""))) - if (ignorefile) ignored.add(ignorefile) - const select = Effect.fnUntraced(function* (reference?: string) { - if (!reference) return { directory: location.directory, root } - const resolved = yield* references.get(reference) - if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`)) - if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message)) - if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie) - return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) } - }) - const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) { - const managed = path.join( - Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }), - ToolOutputStore.MANAGED_DIRECTORY, - ) - if (input && path.isAbsolute(input)) { - if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference")) - if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_")) - return yield* Effect.die(new Error("Absolute path is not managed tool output")) - const real = yield* fs.realPath(input).pipe(Effect.orDie) - const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie) - if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_")) - return yield* Effect.die(new Error("Path escapes managed tool output")) - return { absolute: input, real, directory: managed, root: managedRoot } - } - const selected = yield* select(reference) - const absolute = path.resolve(selected.directory, input ?? ".") - if (!FSUtil.contains(selected.directory, absolute)) + const resolve = Effect.fnUntraced(function* (input?: RelativePath) { + const absolute = path.resolve(location.directory, input ?? ".") + if (!FSUtil.contains(location.directory, absolute)) return yield* Effect.die(new Error("Path escapes the location")) const real = yield* fs.realPath(absolute).pipe(Effect.orDie) - if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location")) - return { absolute, real, ...selected } + if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location")) + return { absolute, real, directory: location.directory, root } }) const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) { const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void)) if (!real) return if (!FSUtil.contains(selected.root, real)) return const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void)) - if (!info) return - const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined + const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined if (!type) return return new Entry({ path: RelativePath.make(path.relative(selected.directory, absolute)), @@ -298,320 +111,73 @@ export const layer = Layer.effect( }) }) - const scan = Effect.fnUntraced(function* () { - if (location.directory === Global.Path.home && location.project.id === "global") { - const protectedNames = Protected.names() - const nested = new Set(["node_modules", "dist", "build", "target", "vendor"]) - return (yield* Effect.forEach( - yield* fs.readDirectoryEntries(location.directory).pipe(Effect.orElseSucceed(() => [])), - (item) => - Effect.gen(function* () { - if (item.type !== "directory" || item.name.startsWith(".") || protectedNames.has(item.name)) return [] - const directory = path.join(location.directory, item.name) - return [ - item.name + "/", - ...(yield* fs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => []))).flatMap((child) => - child.type === "directory" && !child.name.startsWith(".") && !nested.has(child.name) - ? [`${item.name}/${child.name}/`] - : [], - ), - ] - }), - )).flat() - } - - const files = Array.from(yield* ripgrep.files({ cwd: location.directory }).pipe(Stream.runCollect, Effect.orDie)) - const dirs = new Set() - for (const file of files) { - let current = file - while (true) { - const directory = path.dirname(current) - if (directory === "." || directory === current) break - current = directory - dirs.add(directory + "/") - } - } - return [...files, ...dirs] - }) - - const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) { - const target = yield* resolve(input.path, input.reference) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined - if (!type) return yield* Effect.die(new Error("Path is not a file or directory")) - const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "." - return new ReadPath({ - type, - resource: input.reference === undefined ? relative : `${input.reference}:${relative}`, - }) - }) - const resolveFile = Effect.fnUntraced(function* (input: ReadInput) { - const target = yield* resolve(input.path, input.reference) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "." - return { - real: target.real, - resource: input.reference === undefined ? relative : `${input.reference}:${relative}`, - } - }) - const content = (target: { readonly real: string }, bytes: Uint8Array) => - Effect.gen(function* () { + return Service.of({ + read: Effect.fn("FileSystem.read")(function* (input) { + const target = yield* resolve(input.path) + const info = yield* fs.stat(target.real).pipe(Effect.orDie) + if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + const bytes = yield* fs.readFile(target.real).pipe(Effect.orDie) const mime = FSUtil.mimeType(target.real) if (!bytes.includes(0)) { const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe( Effect.option, ) - if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime }) + if (Option.isSome(content)) + return { + uri: pathToFileURL(target.real).href, + name: path.basename(target.real), + content: content.value, + encoding: "utf8" as const, + mime, + } } - return new BinaryContent({ - type: "binary", + return { + uri: pathToFileURL(target.real).href, + name: path.basename(target.real), content: Buffer.from(bytes).toString("base64"), - encoding: "base64", + encoding: "base64" as const, mime, - }) - }) - const readTool = Effect.fn("FileSystem.readTool")(function* (input: ReadInput, page: TextPageInput = {}) { - const target = yield* resolveFile(input) - return yield* Effect.scoped( - Effect.gen(function* () { - const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - - const first = Option.getOrElse( - yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || READ_SAMPLE_BYTES)).pipe(Effect.orDie), - () => new Uint8Array(), - ) - const mime = supportedImageMime(first) - if (mime) { - if (info.size > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES)) - const chunks = [first] - let total = first.length - while (total <= MAX_MEDIA_INGEST_BYTES) { - const chunk = yield* file - .readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) - .pipe(Effect.orDie) - if (Option.isNone(chunk)) break - chunks.push(chunk.value) - total += chunk.value.length - } - if (total > MAX_MEDIA_INGEST_BYTES) - return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES)) - return new BinaryContent({ - type: "binary", - content: Buffer.concat( - chunks.map((chunk) => Buffer.from(chunk)), - total, - ).toString("base64"), - encoding: "base64", - mime, - }) - } - if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || isBinary(target.resource, first)) - return yield* Effect.die(new BinaryFileError(target.resource)) - - const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined - if (!paged) { - const decoder = new TextDecoder("utf-8", { fatal: true }) - const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))] - while (true) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) - if (Option.isNone(chunk)) break - if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(target.resource)) - text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true }))) - } - text.push(yield* Effect.sync(() => decoder.decode())) - return new TextContent({ type: "text", content: text.join(""), mime: FSUtil.mimeType(target.real) }) - } - - const offset = page.offset ?? 1 - const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) - const lines: string[] = [] - const decoder = new TextDecoder("utf-8", { fatal: true }) - let pending = "" - let discard = false - let line = 1 - let bytes = 0 - let found = false - let truncated = false - let next: number | undefined - - const append = (input: string) => { - if (line < offset) { - line++ - return - } - if (lines.length >= limit || bytes >= MAX_READ_BYTES) { - truncated = true - next ??= line - line++ - return - } - found = true - const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input - const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) - if (bytes + size > MAX_READ_BYTES) { - truncated = true - next ??= line - line++ - return - } - lines.push(text) - bytes += size - line++ - } - - const consume = (chunk: Uint8Array) => { - if (chunk.includes(0)) throw new BinaryFileError(target.resource) - let text = decoder.decode(chunk, { stream: true }) - while (true) { - const index = text.indexOf("\n") - if (index === -1) { - if (!discard) { - pending += text - if (pending.length > MAX_LINE_LENGTH) { - pending = pending.slice(0, MAX_LINE_LENGTH + 1) - discard = true - } - } - break - } - const current = pending + (discard ? "" : text.slice(0, index)) - pending = "" - discard = false - text = text.slice(index + 1) - append(current.endsWith("\r") ? current.slice(0, -1) : current) - } - } - - yield* Effect.sync(() => consume(first)) - while (true) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) - if (Option.isNone(chunk)) break - yield* Effect.sync(() => consume(chunk.value)) - } - const tail = yield* Effect.sync(() => decoder.decode()) - if (!discard) pending += tail - if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) - if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`)) - - const text = lines.join("\n") - return new TextPage({ - type: "text-page", - content: text, - mime: FSUtil.mimeType(target.real), - offset, - truncated, - ...(next === undefined ? {} : { next }), - }) - }), - ) - }) - const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) { - const directory = yield* resolve(input.path, input.reference) - const info = yield* fs.stat(directory.real).pipe(Effect.orDie) - if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) - const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "." - return new ListTarget({ - ...directory, - resource: input.reference === undefined ? relative : `${input.reference}:${relative}`, - }) - }) - const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) { - const target = yield* resolve(input.path, input.reference) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined - if (!type) return yield* Effect.die(new Error("Path is not a file or directory")) - const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "." - return new RootTarget({ - ...target, - resource: input.reference === undefined ? relative : `${input.reference}:${relative}`, - reference: input.reference, - type, - }) - }) - const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) { - return yield* fs.readDirectoryEntries(directory.real).pipe( - Effect.orDie, - Effect.flatMap((items) => - Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), { - concurrency: "unbounded", - }), - ), - Effect.map((items) => - items - .filter((item): item is Entry => item !== undefined) - .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)), - ), - ) - }) - const listPageResolved = Effect.fn("FileSystem.listPageResolved")(function* ( - target: ListTarget, - page: Pick = {}, - ) { - type Candidate = Entry | { readonly name: string; readonly type: "file" | "directory" } - const offset = page.offset ?? 1 - const limit = Math.min(page.limit ?? 2_000, 2_000) - const items = yield* fs.readDirectoryEntries(target.real).pipe(Effect.orDie) - const candidates = yield* Effect.forEach( - items, - (item): Effect.Effect => { - if (item.type === "other") return Effect.succeed(undefined) - if (item.type === "symlink") return entry(path.join(target.absolute, item.name), target) - return Effect.succeed({ name: item.name, type: item.type } as const) - }, - { concurrency: 16 }, - ).pipe(Effect.map((items) => items.filter((item): item is Candidate => item !== undefined))) - candidates.sort((a, b) => { - return a.type === b.type - ? (a instanceof Entry ? a.path : a.name).localeCompare(b instanceof Entry ? b.path : b.name) - : a.type === "directory" - ? -1 - : 1 - }) - const selected = candidates.slice(offset - 1, offset - 1 + limit) - const entries = yield* Effect.forEach( - selected, - (item) => (item instanceof Entry ? Effect.succeed(item) : entry(path.join(target.absolute, item.name), target)), - { - concurrency: 16, - }, - ).pipe(Effect.map((items) => items.filter((item): item is Entry => item !== undefined))) - const truncated = offset - 1 + selected.length < candidates.length - return new ListPage({ entries, truncated, ...(truncated ? { next: offset + selected.length } : {}) }) - }) - - return Service.of({ - read: Effect.fn("FileSystem.read")(function* (input) { - const target = yield* resolveFile(input) - return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie)) - }), - resolveReadPath, - readTool, - list: Effect.fn("FileSystem.list")(function* (input) { - return yield* listResolved(yield* resolveList(input)) + } }), - resolveRoot, - resolveList, - listResolved, - listPage: Effect.fn("FileSystem.listPage")(function* (input) { - return yield* listPageResolved(yield* resolveList(input), input) + list: Effect.fn("FileSystem.list")(function* (input = {}) { + const target = yield* resolve(input.path) + const info = yield* fs.stat(target.real).pipe(Effect.orDie) + if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) + return yield* fs.readDirectoryEntries(target.real).pipe( + Effect.orDie, + Effect.flatMap((items) => + Effect.forEach(items, (item) => entry(path.join(target.absolute, item.name), target), { + concurrency: "unbounded", + }), + ), + Effect.map((items) => + items + .filter((item): item is Entry => item !== undefined) + .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)), + ), + ) }), - listPageResolved, find: Effect.fn("FileSystem.find")(function* (input) { - const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/")) - const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/")) - const sorted = input.query.trim() - ? fuzzysort.go(input.query.trim(), filtered, { limit: input.limit ?? 100 }).map((item) => item.target) - : filtered.slice(0, input.limit) - return yield* Effect.forEach(sorted, (item) => entry(path.join(location.directory, item))).pipe( - Effect.map((items) => items.filter((item): item is Entry => item !== undefined)), + const found = yield* search + .file({ + cwd: location.directory, + query: input.query, + limit: input.limit, + kind: input.type ?? "all", + }) + .pipe(Effect.orDie) + return found.map( + (item) => + new Entry({ + path: RelativePath.make(item.path), + uri: pathToFileURL(path.join(location.directory, item.path)).href, + type: item.type, + mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(item.path), + }), ) }), grep: Effect.fn("FileSystem.grep")(function* (input) { - return (yield* ripgrep + return (yield* search .search({ cwd: location.directory, pattern: input.pattern, @@ -633,16 +199,8 @@ export const layer = Layer.effect( }), ) }), - isIgnored: (input, type) => - ignored.ignores( - path.relative(location.project.directory, path.join(location.directory, input)) + - (type === "directory" ? "/" : ""), - ), }) }), ) -export const locationLayer = layer.pipe( - Layer.provide(Ripgrep.defaultLayer), - Layer.provideMerge(ProjectReference.locationLayer), -) +export const locationLayer = layer diff --git a/packages/core/src/filesystem/ripgrep.ts b/packages/core/src/filesystem/ripgrep.ts index b8a171b9e3c6..036d0fb3a7f1 100644 --- a/packages/core/src/filesystem/ripgrep.ts +++ b/packages/core/src/filesystem/ripgrep.ts @@ -10,11 +10,10 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner import { CrossSpawnSpawner } from "../cross-spawn-spawner" import { Global } from "../global" import { NonNegativeInt } from "../schema" -import * as Log from "../util/log" -import { sanitizedProcessEnv } from "../util/opencode-process" import { which } from "../util/which" +import { LayerNode } from "../effect/layer-node" +import { httpClient } from "../effect/layer-node-platform" -const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" const PLATFORM = { "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" }, @@ -146,7 +145,9 @@ export class Service extends Context.Service()("@opencode/Ri export const use = serviceUse(Service) function env() { - const env = sanitizedProcessEnv() + const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) delete env.RIPGREP_CONFIG_PATH return env } @@ -307,7 +308,7 @@ export const layer: Layer.Layer Effect.Effect - readonly file: (input: FileInput) => Effect.Effect + readonly file: (input: FileInput) => Effect.Effect readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError> readonly open: (input: { cwd?: string; file: string }) => Effect.Effect readonly warm: (cwd: string) => Effect.Effect @@ -134,21 +138,25 @@ function item(hit: Fff.Hit): Item { } function collectPaths( - out: { items: T[]; scores: Array<{ total: number }> }, - toPath: (item: T) => string, - opts?: { includeZeroScore?: boolean }, -): string[] { - return Array.from( - new Set( - out.items.flatMap((item, idx): string[] => { - const score = out.scores[idx] - if (!score || (!opts?.includeZeroScore && score.total <= 0)) return [] - const text = toPath(item) - if (!text) return [] - return [text] - }), - ), + items: T[], + scores: Array<{ total: number }>, + toResult: (item: T) => FileResult, +): FileResult[] { + const rows = items.flatMap((item, index): Array => { + const result = toResult(item) + if (!result.path) return [] + return [{ ...result, score: scores[index]?.total ?? 0 }] + }) + rows.sort( + (a, b) => b.score - a.score || a.path.length - b.path.length || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0), ) + + const seen = new Set() + return rows.flatMap((item) => { + if (seen.has(item.path)) return [] + seen.add(item.path) + return [{ path: item.path, type: item.type }] + }) } function searchFff( @@ -156,13 +164,16 @@ function searchFff( kind: "file" | "directory" | "all", query: string, opts: { currentFile?: string; pageIndex?: number; pageSize?: number }, -): Fff.Result { +): Fff.Result { if (kind === "directory") { const out = pick.directorySearch(query, opts) if (!out.ok) return out return { ok: true, - value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }), + value: collectPaths(out.value.items, out.value.scores, (entry) => ({ + path: normalize(entry.relativePath), + type: "directory", + })), } } if (kind === "all") { @@ -170,14 +181,20 @@ function searchFff( if (!out.ok) return out return { ok: true, - value: collectPaths(out.value, (entry) => normalize(entry.item.relativePath), { includeZeroScore: !query }), + value: collectPaths(out.value.items, out.value.scores, (entry) => ({ + path: normalize(entry.item.relativePath), + type: entry.type, + })), } } const out = pick.fileSearch(query, opts) if (!out.ok) return out return { ok: true, - value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }), + value: collectPaths(out.value.items, out.value.scores, (entry) => ({ + path: normalize(entry.relativePath), + type: "file", + })), } } @@ -225,30 +242,20 @@ export const layer: Layer.Layer pick.destroy()).pipe(Effect.ignore) state.pick.delete(dir) - log.warn("fff scan not ready", { dir }) + yield* Effect.logWarning("fff scan not ready", { dir }) return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error)) } const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus()) - if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error }) + if (!git.ok) { + yield* Effect.logWarning("fff git refresh failed", { dir, error: git.error }) + } }) // Create (or return) the picker for a directory. Creation is synchronous // and does not await the scan; the native background scan starts as soon as // the picker exists. The `wait` gate dedupes concurrent creation. const acquire = Effect.fn("Search.acquire")(function* (cwd: string) { - // The opencode test runtime owns an isolated XDG tree that Windows must - // remove before process exit, so use ripgrep instead of native FFF there. - if (process.env.OPENCODE_TEST_HOME) return undefined - - const available = yield* fffSync("check availability", () => Fff.available()).pipe( - Effect.catch((error) => { - log.warn("fff availability check failed", { error }) - return Effect.succeed(false) - }), - ) - if (!available) return undefined - const dir = FSUtil.resolve(cwd) const existing = state.pick.get(dir) if (existing) return existing @@ -256,6 +263,11 @@ export const layer: Layer.Layer Fff.available()).pipe( + Effect.catch((error) => Effect.logWarning("fff availability check failed", { error }).pipe(Effect.as(false))), + ) + if (!available) return undefined + const gate = yield* Deferred.make() state.wait.set(dir, gate) return yield* Effect.gen(function* () { @@ -266,10 +278,6 @@ export const layer: Layer.Layer, aiMode: true, // only the first toolcall picker can accumulate resources to index // home directory, if the user specifically opened opencode at the @@ -281,7 +289,7 @@ export const layer: Layer.Layer - searchFff(pick, kind, query, { + searchFff(entry.pick, kind, query, { pageIndex: 0, currentFile: input.current, // supports both relative and absolute (relative preferred) pageSize: limit, }), ).pipe( - Effect.catch((error) => { - log.warn(`fff ${kind} search failed`, { dir, query, error }) - return Effect.succeed | undefined>(undefined) - }), + Effect.catch((error) => + Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe( + Effect.andThen(Effect.fail(error)), + ), + ), ) - if (!fffResult) return undefined if (!fffResult.ok) { - log.warn(`fff ${kind} search failed`, { dir, query, error: fffResult.error }) - return undefined + yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error }) + return yield* Effect.fail(new Error(fffResult.error)) } const rows = fffResult.value @@ -381,7 +389,7 @@ export const layer: Layer.Layer path.join(dir, row)), + rows.map((row) => path.join(dir, row.path)), ) return rows.slice(0, limit) }) @@ -403,14 +411,15 @@ export const layer: Layer.Layer { - log.warn("fff grep failed", { dir, pattern: input.pattern, error }) - return Effect.succeed | undefined>(undefined) - }), + Effect.catch((error) => + Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error }).pipe( + Effect.as | undefined>(undefined), + ), + ), ) if (!fffGrep) return yield* rip(input) if (!fffGrep.ok) { - log.warn("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error }) + yield* Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error }) return yield* rip(input) } @@ -442,10 +451,11 @@ export const layer: Layer.Layer { - log.warn("fff glob failed", { dir, pattern: input.pattern, error }) - return Effect.succeed | undefined>(undefined) - }), + Effect.catch((error) => + Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error }).pipe( + Effect.as | undefined>(undefined), + ), + ), ) if (fffGlob?.ok) { @@ -463,7 +473,7 @@ export const layer: Layer.Layer rows.length, } } else if (fffGlob) { - log.warn("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error }) + yield* Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error }) // fall through to the fallback } } @@ -510,13 +520,16 @@ export const layer: Layer.Layer entry.pick.trackQuery(row.text, file)).pipe( - Effect.catch((error) => { - log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error }) - return Effect.succeed | undefined>(undefined) - }), + Effect.catch((error) => + Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error }).pipe( + Effect.as | undefined>(undefined), + ), + ), ) if (!out) return - if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error }) + if (!out.ok) { + yield* Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error }) + } }) return Service.of({ files, tree, search, file, glob, open, warm, release }) @@ -527,6 +540,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(Ripgrep.defaultLayer), Layer.provide(FSUtil.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, Ripgrep.node]) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index c5d8f95a2b96..65d85e048233 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -12,13 +12,11 @@ import { FSUtil } from "../fs-util" import { Git } from "../git" import { Location } from "../location" import { lazy } from "../util/lazy" -import * as Log from "../util/log" import { Ignore } from "./ignore" import { Protected } from "./protected" declare const OPENCODE_LIBC: string | undefined -const log = Log.create({ service: "file.watcher" }) const SUBSCRIBE_TIMEOUT_MS = 10_000 export const Event = { @@ -38,8 +36,7 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`, ) return createWrapper(binding) as typeof import("@parcel/watcher") - } catch (error) { - log.error("failed to load watcher binding", { error }) + } catch { return } }) @@ -71,14 +68,17 @@ export const layer = Layer.effect( const backend = getBackend() const location = yield* Location.Service if (!backend) { - log.error("watcher backend not supported", { directory: location.directory, platform: process.platform }) + yield* Effect.logError("watcher backend not supported", { + directory: location.directory, + platform: process.platform, + }) return Service.of({}) } const w = watcher() if (!w) return Service.of({}) - log.info("watcher backend", { directory: location.directory, platform: process.platform, backend }) + yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend }) const events = yield* EventV2.Service const fs = yield* FSUtil.Service const git = yield* Git.Service @@ -103,9 +103,8 @@ export const layer = Layer.effect( Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))), Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.catchCause((cause) => { - log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) }) pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) - return Effect.void + return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) }) }), ) } @@ -133,8 +132,9 @@ export const layer = Layer.effect( return Service.of({}) }).pipe( Effect.catchCause((cause) => { - log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) - return Effect.succeed(Service.of({})) + return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe( + Effect.as(Service.of({})), + ) }), ), ) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 804a385bca32..b8b655c883ce 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -46,7 +46,6 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), - OPENCODE_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("OPENCODE_EXPERIMENTAL_SESSION_SWITCHER"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index 82ddd40483cc..24263cbadf93 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -7,6 +7,8 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" import { serviceUse } from "./effect/service-use" +import { LayerNode } from "./effect/layer-node" +import { filesystem } from "./effect/layer-node-platform" export namespace FSUtil { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { @@ -194,6 +196,7 @@ export namespace FSUtil { ) export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) + export const node = LayerNode.make(layer, [filesystem]) // Pure helpers that don't need Effect (path manipulation, sync operations) export function mimeType(p: string): string { diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index fd35ad970c63..0041c3353f43 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -6,6 +6,7 @@ import { ChildProcess } from "effect/unstable/process" import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" import { AppProcess } from "./process" +import { LayerNode } from "./effect/layer-node" export interface Repo { /** @@ -400,6 +401,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer)) +export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node]) export interface Result { readonly exitCode: number diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 5f9799c2524a..2a0ac95d1a5c 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,6 +5,7 @@ import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" import { Flag } from "./flag/flag" +import { LayerNode } from "./effect/layer-node" const app = "opencode" const data = path.join(xdgData!, app) @@ -76,6 +77,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer +export const node = LayerNode.make(layer, []) export const layerWith = (input: Partial) => Layer.effect( diff --git a/packages/core/src/image.ts b/packages/core/src/image.ts index 52661e89b0e3..9c69d6aeb600 100644 --- a/packages/core/src/image.ts +++ b/packages/core/src/image.ts @@ -34,8 +34,11 @@ export class SizeError extends Schema.TaggedErrorClass()("Image.SizeE export interface Interface { readonly normalize: ( resource: string, - content: FileSystem.BinaryContent, - ) => Effect.Effect + content: FileSystem.Content & { readonly encoding: "base64" }, + ) => Effect.Effect< + FileSystem.Content & { readonly encoding: "base64" }, + ResizerUnavailableError | DecodeError | SizeError + > } export class Service extends Context.Service()("@opencode/Image") {} @@ -50,7 +53,10 @@ export const layer = Layer.effect( catch: () => new ResizerUnavailableError(), }).pipe(Effect.flatMap((adapter) => adapter.make)), ) - const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) { + const normalize = Effect.fn("Image.normalize")(function* ( + resource: string, + content: FileSystem.Content & { readonly encoding: "base64" }, + ) { const image = Object.assign( {}, ...(yield* config.entries()).flatMap((entry) => diff --git a/packages/core/src/image/photon.ts b/packages/core/src/image/photon.ts index b78ea7930693..e4a00ce5fc8c 100644 --- a/packages/core/src/image/photon.ts +++ b/packages/core/src/image/photon.ts @@ -19,7 +19,7 @@ export const make = Effect.gen(function* () { ) return Effect.fn("Image.Photon.normalize")(function* ( resource: string, - content: FileSystem.BinaryContent, + content: FileSystem.Content & { readonly encoding: "base64" }, limits: { readonly autoResize: boolean readonly maxWidth: number @@ -72,7 +72,7 @@ export const make = Effect.gen(function* () { for (const [mime, encode] of encoders) { const candidate = Buffer.from(encode()).toString("base64") if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes) - return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime }) + return { ...content, content: candidate, encoding: "base64" as const, mime } } } finally { resized.free() diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index e53cad579ec8..1b2c313bad06 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -19,10 +19,11 @@ import { PermissionV2 } from "./permission" import { PermissionSaved } from "./permission/saved" import { FileSystem } from "./filesystem" import { Watcher } from "./filesystem/watcher" +import { Search } from "./filesystem/search" import { LocationMutation } from "./location-mutation" import { LocationSearch } from "./location-search" import { FileMutation } from "./file-mutation" -import { ProjectReference } from "./project-reference" +import { Reference } from "./reference" import { RepositoryCache } from "./repository-cache" import { Pty } from "./pty" import { SkillV2 } from "./skill" @@ -52,7 +53,7 @@ export class LocationServiceMap extends LayerMap.Service()(" location, Policy.locationLayer, Config.locationLayer, - ProjectReference.locationLayer, + Reference.locationLayer, PluginV2.locationLayer, Catalog.locationLayer, CommandV2.locationLayer, @@ -124,5 +125,6 @@ export class LocationServiceMap extends LayerMap.Service()(" FetchHttpClient.layer, ToolOutputStore.defaultCleanupLayer, ApplicationTools.layer, + Search.defaultLayer, ], }) {} diff --git a/packages/core/src/location-search.ts b/packages/core/src/location-search.ts index 7b05132a029e..a23f3b60cff7 100644 --- a/packages/core/src/location-search.ts +++ b/packages/core/src/location-search.ts @@ -2,15 +2,16 @@ export * as LocationSearch from "./location-search" import path from "path" import { Context, Effect, Layer, Option, Schema } from "effect" -import { FileSystem } from "./filesystem" import { FSUtil } from "./fs-util" +import { Global } from "./global" +import { Location } from "./location" import { Ripgrep } from "./ripgrep" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" +import { ToolOutputStore } from "./tool-output-store" /** * Location-scoped raw search substrate. Search authority is selected only by - * FileSystem, preserving Location-relative paths and named read - * references. Model formatting, leaf-tool permissions, and HTTP transport stay + * FileSystem, preserving Location-relative paths. Model formatting, leaf-tool permissions, and HTTP transport stay * outside this service so future GlobTool, GrepTool, and HTTP consumers can * share the same bounded filesystem behavior. * @@ -26,7 +27,7 @@ export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESU export const FilesInput = Schema.Struct({ pattern: Schema.String, - ...FileSystem.ListInput.fields, + path: Schema.String.pipe(Schema.optional), limit: ResultLimit.pipe(Schema.optional), }) export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal } @@ -34,7 +35,7 @@ export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSigna export const GrepInput = Schema.Struct({ pattern: Schema.String, include: Schema.String.pipe(Schema.optional), - ...FileSystem.ListInput.fields, + path: Schema.String.pipe(Schema.optional), limit: ResultLimit.pipe(Schema.optional), }) export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal } @@ -90,10 +91,38 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service - const filesystem = yield* FileSystem.Service + const location = yield* Location.Service + const global = yield* Effect.serviceOption(Global.Service) const ripgrep = yield* Ripgrep.Service - const candidate = Effect.fnUntraced(function* (root: FileSystem.RootTarget, cwd: string, value: string) { + const resolve = Effect.fnUntraced(function* (input?: string) { + const directory = input && path.isAbsolute(input) ? path.dirname(input) : location.directory + const absolute = path.resolve(location.directory, input ?? ".") + if (!path.isAbsolute(input ?? "") && !FSUtil.contains(location.directory, absolute)) + return yield* Effect.die(new globalThis.Error("Path escapes the location")) + if (path.isAbsolute(input ?? "")) { + const managed = path.join( + Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }), + ToolOutputStore.MANAGED_DIRECTORY, + ) + if (directory !== managed || !path.basename(absolute).startsWith("tool_")) + return yield* Effect.die(new globalThis.Error("Absolute path is not managed tool output")) + } + const real = yield* fs.realPath(absolute).pipe(Effect.orDie) + const root = yield* fs.realPath(directory).pipe(Effect.orDie) + if (!FSUtil.contains(root, real)) return yield* Effect.die(new globalThis.Error("Path escapes the search root")) + const info = yield* fs.stat(real).pipe(Effect.orDie) + const type = + info.type === "File" ? ("file" as const) : info.type === "Directory" ? ("directory" as const) : undefined + if (!type) return yield* Effect.die(new globalThis.Error("Search root is not a file or directory")) + return { real, root, resource: slash(path.relative(root, real)) || ".", type } + }) + + const candidate = Effect.fnUntraced(function* ( + root: { readonly real: string; readonly root: string; readonly type: "file" | "directory" }, + cwd: string, + value: string, + ) { const absolute = path.resolve(cwd, value) const lexicallyContained = root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real @@ -106,7 +135,7 @@ export const layer = Layer.effect( return { path: RelativePath.make(relative), canonical, - resource: root.reference === undefined ? relative : `${root.reference}:${relative}`, + resource: relative, mtime: info.mtime.pipe( Option.map((date) => date.getTime()), Option.getOrElse(() => 0), @@ -116,7 +145,7 @@ export const layer = Layer.effect( return Service.of({ files: Effect.fn("LocationSearch.files")(function* (input) { - const root = yield* filesystem.resolveRoot(input) + const root = yield* resolve(input.path) if (root.type !== "directory") return yield* Effect.die(new globalThis.Error("Files search path must be a directory")) const result = yield* ripgrep.files({ @@ -138,7 +167,7 @@ export const layer = Layer.effect( }) }), grep: Effect.fn("LocationSearch.grep")(function* (input) { - const root = yield* filesystem.resolveRoot(input) + const root = yield* resolve(input.path) const cwd = root.type === "directory" ? root.real : path.dirname(root.real) const result = yield* ripgrep.grep({ cwd, diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 64eede422d85..3f9f670374e5 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -8,6 +8,8 @@ import { Hash } from "./util/hash" import { FSUtil } from "./fs-util" import { InstallationChannel, InstallationVersion } from "./installation/version" import { EventV2 } from "./event" +import { LayerNode } from "./effect/layer-node" +import { httpClient } from "./effect/layer-node-platform" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type @@ -54,7 +56,7 @@ export const Model = Schema.Struct({ Schema.Union([ Schema.Literal(true), Schema.Struct({ - field: Schema.Literals(["reasoning_content", "reasoning_details"]), + field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), }), ]), ), @@ -227,9 +229,7 @@ export const layer = Layer.effect( yield* events.publish(Event.Refreshed, {}) }), ).pipe( - Effect.tapCause((cause) => - Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)), - ), + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), Effect.ignore, ) }) @@ -248,5 +248,6 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(EventV2.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient]) export * as ModelsDev from "./models-dev" diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 759e0487051f..f3398e83911c 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -7,6 +7,8 @@ import { NodeFileSystem } from "@effect/platform-node" import { FSUtil } from "./fs-util" import { Global } from "./global" import { EffectFlock } from "./util/effect-flock" +import { LayerNode } from "./effect/layer-node" +import { filesystem } from "./effect/layer-node-platform" import { makeRuntime } from "./effect/runtime" import { NpmConfig } from "./npm-config" @@ -250,6 +252,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), ) +export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node]) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts new file mode 100644 index 000000000000..faffb2733333 --- /dev/null +++ b/packages/core/src/observability.ts @@ -0,0 +1,21 @@ +export * as Observability from "./observability" + +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Logger, References } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { OtlpSerialization } from "effect/unstable/observability" +import { Logging } from "./observability/logging" +import { Otlp } from "./observability/otlp" + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe( + Layer.provide(NodeFileSystem.layer), + Layer.provide(OtlpSerialization.layerJson), + Layer.provide(FetchHttpClient.layer), + Layer.orDie, + Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), + ) + return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer)) + }), +) diff --git a/packages/core/src/observability/logging.ts b/packages/core/src/observability/logging.ts new file mode 100644 index 000000000000..0047d8d5e3fd --- /dev/null +++ b/packages/core/src/observability/logging.ts @@ -0,0 +1,71 @@ +import { Formatter, Logger, type LogLevel } from "effect" +import path from "path" +import { Global } from "../global" +import { runID } from "./shared" + +function formatter(id: string = runID) { + return Logger.map(Logger.formatStructured, (output) => { + const messages = Array.isArray(output.message) ? output.message : [output.message] + return [ + ["timestamp", output.timestamp], + ["level", output.level], + ["run", id], + ...messages.flatMap((value) => (plain(value) ? flatten(value) : [["message", value] as const])), + ...(output.cause === undefined ? [] : [["cause", output.cause] as const]), + ...flatten(output.spans), + ...flatten(output.annotations), + ] + .map(([key, value]) => `${key}=${format(value)}`) + .join(" ") + }) +} + +function flatten( + input: Record, + prefix = "", + seen = new WeakSet(), +): Array { + if (seen.has(input)) return [[prefix, "[Circular]"]] + seen.add(input) + const entries = Object.entries(input) + if (entries.length === 0 && prefix) return [[prefix, input]] + return entries.flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key + return plain(value) ? flatten(value, path, seen) : [[path, value] as const] + }) +} + +function plain(input: unknown): input is Record { + if (input === null || typeof input !== "object" || Array.isArray(input)) return false + const prototype = Object.getPrototypeOf(input) + return prototype === Object.prototype || prototype === null +} + +function format(input: unknown) { + const value = typeof input === "string" ? input : Formatter.format(input) + return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value) +} + +export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) { + // Do not set batchWindow to 0; it causes high idle CPU usage. + return Logger.toFile(formatter(id), file, { flag: "a" }) +} + +const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n")) + +export function minimumLogLevel() { + const value = process.env.OPENCODE_LOG_LEVEL?.toUpperCase() + const levels = { + DEBUG: "Debug", + INFO: "Info", + WARN: "Warn", + ERROR: "Error", + } as const satisfies Record + return value && value in levels ? levels[value as keyof typeof levels] : levels.INFO +} + +export function loggers() { + return process.env.OPENCODE_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()] +} + +export * as Logging from "./logging" diff --git a/packages/core/src/observability/otlp.ts b/packages/core/src/observability/otlp.ts new file mode 100644 index 000000000000..dd99ebc1436b --- /dev/null +++ b/packages/core/src/observability/otlp.ts @@ -0,0 +1,79 @@ +import { Layer } from "effect" +import { OtlpLogger } from "effect/unstable/observability" +import { Flag } from "../flag/flag" +import { InstallationChannel, InstallationVersion } from "../installation/version" +import { runID } from "./shared" + +const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT + +const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS + ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( + (acc, entry) => { + const [key, ...value] = entry.split("=") + acc[key] = value.join("=") + return acc + }, + {} as Record, + ) + : undefined + +function resourceAttributes() { + const value = process.env.OTEL_RESOURCE_ATTRIBUTES + if (!value) return {} + try { + return Object.fromEntries( + value.split(",").map((entry) => { + const index = entry.indexOf("=") + if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry") + return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))] + }), + ) + } catch { + return {} + } +} + +export function resource(): { serviceName: string; serviceVersion: string; attributes: Record } { + return { + serviceName: "opencode", + serviceVersion: InstallationVersion, + attributes: { + ...resourceAttributes(), + "deployment.environment.name": InstallationChannel, + "opencode.client": Flag.OPENCODE_CLIENT, + "opencode.run": runID, + "service.instance.id": runID, + }, + } +} + +export function loggers() { + if (!endpoint) return [] + return [OtlpLogger.make({ url: `${endpoint}/v1/logs`, resource: resource(), headers })] +} + +export async function tracingLayer() { + if (!endpoint) return Layer.empty + const NodeSdk = await import("@effect/opentelemetry/NodeSdk") + const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") + const SdkBase = await import("@opentelemetry/sdk-trace-base") + const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks") + const { context } = await import("@opentelemetry/api") + + // The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans. + const manager = new AsyncLocalStorageContextManager() + manager.enable() + context.setGlobalContextManager(manager) + + return NodeSdk.layer(() => ({ + resource: resource(), + spanProcessor: new SdkBase.BatchSpanProcessor( + new OTLP.OTLPTraceExporter({ + url: `${endpoint}/v1/traces`, + headers, + }), + ), + })) +} + +export * as Otlp from "./otlp" diff --git a/packages/core/src/observability/shared.ts b/packages/core/src/observability/shared.ts new file mode 100644 index 000000000000..76393aacce5a --- /dev/null +++ b/packages/core/src/observability/shared.ts @@ -0,0 +1 @@ +export const runID = crypto.randomUUID().slice(0, 8) diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 6a589a1ef0d6..b59769a0cf2d 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -9,6 +9,7 @@ import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" import { ConfigSkillPlugin } from "../config/plugin/skill" +import { ConfigReferencePlugin } from "../config/plugin/reference" import { EventV2 } from "../event" import { FSUtil } from "../fs-util" import { Global } from "../global" @@ -25,6 +26,7 @@ import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillV2 } from "../skill" +import { Reference } from "../reference" type Plugin = { id: PluginV2.ID @@ -42,6 +44,7 @@ type Plugin = { | Config.Service | ModelsDev.Service | SkillV2.Service + | Reference.Service > } @@ -67,6 +70,7 @@ export const layer = Layer.effect( const fs = yield* FSUtil.Service const global = yield* Global.Service const skill = yield* SkillV2.Service + const references = yield* Reference.Service const done = yield* Deferred.make() const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { @@ -85,6 +89,7 @@ export const layer = Layer.effect( Effect.provideService(FSUtil.Service, fs), Effect.provideService(Global.Service, global), Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, references), Effect.provideService(PluginV2.Service, plugin), ), }) @@ -104,6 +109,7 @@ export const layer = Layer.effect( yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin) + yield* add(ConfigReferencePlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -124,4 +130,5 @@ export const locationLayer = layer.pipe( Layer.provideMerge(Config.locationLayer), Layer.provideMerge(AgentV2.locationLayer), Layer.provideMerge(SkillV2.locationLayer), + Layer.provideMerge(Reference.locationLayer), ) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index 4555b28017a0..44418d74c1bd 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -3,6 +3,7 @@ import type { PlatformError } from "effect/PlatformError" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "./cross-spawn-spawner" +import { LayerNode } from "./effect/layer-node" export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { command: Schema.String, @@ -230,5 +231,6 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) +export const node = LayerNode.make(layer, [CrossSpawnSpawner.node]) export * as AppProcess from "./process" diff --git a/packages/core/src/project-reference.ts b/packages/core/src/project-reference.ts deleted file mode 100644 index 513b83b56323..000000000000 --- a/packages/core/src/project-reference.ts +++ /dev/null @@ -1,241 +0,0 @@ -export * as ProjectReference from "./project-reference" - -import path from "path" -import { Context, Effect, Layer } from "effect" -import { Config } from "./config" -import { ConfigReference } from "./config/reference" -import { FSUtil } from "./fs-util" -import { Flag } from "./flag/flag" -import { Global } from "./global" -import { Location } from "./location" -import { Repository } from "./repository" -import { RepositoryCache } from "./repository-cache" - -export type Resolved = - | { readonly name: string; readonly kind: "local"; readonly path: string } - | { - readonly name: string - readonly kind: "git" - readonly repository: string - readonly reference: Repository.RemoteReference - readonly path: string - readonly branch?: string - } - | { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string } - -type Valid = Exclude - -export type Mention = - | { - readonly name: string - readonly kind: "reference" - readonly reference: Valid - readonly target?: string - readonly path: string - } - | { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string } - | { - readonly name: string - readonly kind: "missing" - readonly target: string - readonly path: string - readonly message: string - } - -export interface Interface { - readonly list: () => Effect.Effect - readonly get: (name: string) => Effect.Effect - readonly resolveMention: (value: string) => Effect.Effect - readonly ensurePath: (target?: string) => Effect.Effect - readonly containsManagedPath: (target?: string) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/ProjectReference") {} - -type Materializer = { - readonly name: string - readonly repository: string - readonly path: string - readonly run: Effect.Effect -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert) - - const config = yield* Config.Service - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const location = yield* Location.Service - const cache = yield* RepositoryCache.Service - const references = resolveAll({ - references: ConfigReference.normalize( - Object.assign( - {}, - ...(yield* config.entries()) - .filter((entry): entry is Config.Document => entry.type === "document") - .map((document) => document.info.references ?? {}), - ), - ), - directory: location.project.directory, - home: global.home, - repos: global.repos, - }) - const materializers = yield* Effect.forEach( - uniqueGitReferences(references), - Effect.fnUntraced(function* (reference) { - return { - name: reference.name, - repository: reference.repository, - path: reference.path, - run: yield* Effect.cached( - cache - .ensure({ reference: reference.reference, branch: reference.branch, refresh: true }) - .pipe(Effect.asVoid), - ), - } - }), - ) - - yield* Effect.forEach( - materializers, - (materializer) => - materializer.run.pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to materialize project reference").pipe( - Effect.annotateLogs({ name: materializer.name, repository: materializer.repository, cause }), - ), - ), - ), - { concurrency: 4, discard: true }, - ).pipe(Effect.forkScoped) - - const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) { - const normalized = normalizePath(target) - if (!normalized) - return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true }) - yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void - }) - - return Service.of({ - list: Effect.fn("ProjectReference.list")(function* () { - return references - }), - get: Effect.fn("ProjectReference.get")(function* (name: string) { - return references.find((reference) => reference.name === name) - }), - ensurePath, - containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) { - const normalized = normalizePath(target) - return normalized - ? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized)) - : false - }), - resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) { - const [name, ...rest] = value.split("/") - const target = rest.length ? rest.join("/") : undefined - const reference = references.find((reference) => reference.name === name) - if (!reference) return - if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message } - if (reference.kind === "git") yield* ensurePath(reference.path) - if (!target) return { name, kind: "reference", reference, path: reference.path } - - const resolved = path.resolve(reference.path, target) - if (!FSUtil.contains(reference.path, resolved)) - return { name, kind: "invalid", target, message: "Reference target escapes its root" } - if (!(yield* fs.existsSafe(resolved))) - return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" } - return { name, kind: "reference", reference, target, path: resolved } - }), - }) - }), -) - -export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer)) - -const inert: Interface = { - list: () => Effect.succeed([]), - get: () => Effect.succeed(undefined), - resolveMention: () => Effect.succeed(undefined), - ensurePath: () => Effect.void, - containsManagedPath: () => Effect.succeed(false), -} - -export function resolveAll(input: { - references: ConfigReference.NormalizedInfo - directory: string - home: string - repos: string -}) { - const seen = new Map() - return Object.entries(input.references).map(([name, reference]): Resolved => { - const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos }) - if (resolved.kind !== "git") return resolved - const existing = seen.get(resolved.path) - if (!existing) { - seen.set(resolved.path, { name, branch: resolved.branch }) - return resolved - } - if (existing.branch === resolved.branch) return resolved - return { - name, - kind: "invalid", - repository: resolved.repository, - message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`, - } - }) -} - -export function resolve(input: { - name: string - reference: ConfigReference.NormalizedEntry - directory: string - home: string - repos: string -}): Resolved { - if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message } - if (input.reference.kind === "local") { - return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) } - } - const reference = Repository.parse(input.reference.repository) - if (!reference || !Repository.isRemote(reference)) { - return { - name: input.name, - kind: "invalid", - repository: input.reference.repository, - message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", - } - } - return { - name: input.name, - kind: "git", - repository: input.reference.repository, - reference, - path: Repository.cachePath(input.repos, reference), - branch: input.reference.branch, - } -} - -function localPath(directory: string, home: string, value: string) { - if (value.startsWith("~/")) return path.join(home, value.slice(2)) - return path.isAbsolute(value) ? value : path.resolve(directory, value) -} - -function uniqueGitReferences(references: Resolved[]) { - const seen = new Set() - return references.filter((reference): reference is Extract => { - if (reference.kind !== "git" || seen.has(reference.path)) return false - seen.add(reference.path) - return true - }) -} - -function normalizePath(target?: string) { - if (!target) return - return process.platform === "win32" ? FSUtil.normalizePath(target) : target -} - -function contains(parent: string, child: string) { - return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child) -} diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index aa1c3a616157..a7589c1219ef 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -8,6 +8,7 @@ import { AbsolutePath, withStatics } from "./schema" import { FSUtil } from "./fs-util" import { Database } from "./database/database" import { Git } from "./git" +import { LayerNode } from "./effect/layer-node" import { Hash } from "./util/hash" import { ProjectDirectoryTable } from "./project/sql" @@ -36,7 +37,12 @@ export const DirectoriesInput = Schema.Struct({ }).annotate({ identifier: "Project.DirectoriesInput" }) export type DirectoriesInput = typeof DirectoriesInput.Type -export const Directories = Schema.Array(AbsolutePath).annotate({ identifier: "Project.Directories" }) +export const Directories = Schema.Array( + Schema.Struct({ + directory: AbsolutePath, + type: Schema.Literals(["main", "root", "git_worktree"]), + }), +).annotate({ identifier: "Project.Directories" }) export type Directories = typeof Directories.Type export interface Interface { @@ -73,13 +79,13 @@ export const layer = Layer.effect( const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) { const rows = yield* db - .select({ directory: ProjectDirectoryTable.directory }) + .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type }) .from(ProjectDirectoryTable) .where(eq(ProjectDirectoryTable.project_id, input.projectID)) .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory)) .all() .pipe(Effect.orDie) - return rows.map((row) => AbsolutePath.make(row.directory)) + return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type })) }) const cached = Effect.fnUntraced(function* (dir: string) { @@ -154,3 +160,4 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), ) +export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node]) diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 4c5743d13f99..de2beda98167 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -8,6 +8,7 @@ import { FSUtil } from "../fs-util" import { Git } from "../git" import { Database } from "../database/database" import { EventV2 } from "../event" +import { LayerNode } from "../effect/layer-node" import { Project } from "../project" import { ProjectDirectoryTable } from "./sql" import { makeStrategies } from "./copy-strategies" @@ -275,3 +276,4 @@ export const defaultLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(EventV2.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node]) diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index db377df22a91..190cf2349790 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -7,9 +7,7 @@ import { Location } from "./location" import { NonNegativeInt, PositiveInt } from "./schema" import { PtyID } from "./pty/schema" import { lazy } from "./util/lazy" -import * as Log from "./util/log" -const log = Log.create({ service: "pty" }) const BUFFER_LIMIT = 1024 * 1024 * 2 const BUFFER_CHUNK = 64 * 1024 const encoder = new TextEncoder() @@ -158,7 +156,7 @@ export const layer = Layer.effect( const session = sessions.get(id) if (!session) return false sessions.delete(id) - log.info("removing session", { id }) + yield* Effect.logInfo("removing session", { id }) teardown(session) yield* events.publish(Event.Deleted, { id: session.info.id }) return true @@ -179,7 +177,7 @@ export const layer = Layer.effect( const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) { const id = PtyID.ascending() - log.info("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd }) + yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd }) const { spawn } = yield* Effect.promise(() => pty()) const proc = yield* Effect.sync(() => spawn(input.command, input.args, { @@ -231,7 +229,7 @@ export const layer = Layer.effect( if (session.info.status === "exited") return runFork( Effect.gen(function* () { - log.info("session exited", { id, exitCode }) + yield* Effect.logInfo("session exited", { id, exitCode }) session.info.status = "exited" yield* events.publish(Event.Exited, { id, exitCode }) yield* removeSession(id) @@ -263,7 +261,7 @@ export const layer = Layer.effect( const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) { const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close()))) - log.info("client connected to session", { id, directory: location.directory }) + yield* Effect.logInfo("client connected to session", { id, directory: location.directory }) const sub = sock(ws) session.subscribers.delete(sub) session.subscribers.set(sub, ws) @@ -299,7 +297,6 @@ export const layer = Layer.effect( session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message)) }, onClose: () => { - log.info("client disconnected from session", { id }) cleanup() }, } diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index 1d2452cda569..c625390be02f 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -4,6 +4,7 @@ import { WorkspaceV2 } from "../workspace" import { PositiveInt } from "../schema" import { PtyID } from "./schema" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" +import { LayerNode } from "../effect/layer-node" const DEFAULT_TTL = Duration.seconds(60) const CAPACITY = 10_000 @@ -56,3 +57,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) => export const layer = Layer.effect(Service, make()) export const defaultLayer = layer +export const node = LayerNode.make(layer, []) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts new file mode 100644 index 000000000000..9c354f55c3cd --- /dev/null +++ b/packages/core/src/reference.ts @@ -0,0 +1,114 @@ +export * as Reference from "./reference" + +import { Context, Effect, Layer, Schema, Scope } from "effect" +import { castDraft } from "immer" +import { Global } from "./global" +import { EventV2 } from "./event" +import { Repository } from "./repository" +import { RepositoryCache } from "./repository-cache" +import { AbsolutePath } from "./schema" +import { State } from "./state" + +export class Info extends Schema.Class("Reference.Info")({ + name: Schema.String, + path: AbsolutePath, + source: Schema.suspend(() => Source), +}) {} + +export class LocalSource extends Schema.Class("Reference.LocalSource")({ + type: Schema.Literal("local"), + path: AbsolutePath, +}) {} + +export class GitSource extends Schema.Class("Reference.GitSource")({ + type: Schema.Literal("git"), + repository: Schema.String, + branch: Schema.String.pipe(Schema.optional), +}) {} + +export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type")) +export type Source = typeof Source.Type + +export const Event = { + Updated: EventV2.define({ type: "reference.updated", schema: {} }), +} + +type Data = { + sources: Map +} + +type Editor = { + add(name: string, source: Source): void + remove(name: string): void + list(): readonly [string, Source][] +} + +export interface Interface { + readonly transform: State.Interface["transform"] + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Reference") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const global = yield* Global.Service + const events = yield* EventV2.Service + const cache = yield* RepositoryCache.Service + const scope = yield* Scope.Scope + const materialized = new Map() + const state = State.create({ + initial: () => ({ sources: new Map() }), + editor: (draft) => ({ + add: (name, source) => draft.sources.set(name, castDraft(source)), + remove: (name) => draft.sources.delete(name), + list: () => Array.from(draft.sources.entries()) as [string, Source][], + }), + finalize: (editor) => + Effect.gen(function* () { + materialized.clear() + const seen = new Map() + for (const [name, source] of editor.list()) { + if (source.type === "local") { + materialized.set(name, new Info({ name, path: source.path, source })) + continue + } + const repository = Repository.parse(source.repository) + if (!repository || !Repository.isRemote(repository)) continue + if (source.branch) { + try { + Repository.validateBranch(source.branch) + } catch { + continue + } + } + const target = Repository.cachePath(global.repos, repository) + if (seen.has(target) && seen.get(target) !== source.branch) continue + seen.set(target, source.branch) + materialized.set(name, new Info({ name, path: AbsolutePath.make(target), source })) + yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to materialize reference", { + name, + repository: source.repository, + cause, + }), + ), + Effect.forkIn(scope), + ) + } + yield* events.publish(Event.Updated, {}) + }), + }) + + return Service.of({ + transform: state.transform, + list: Effect.fn("Reference.list")(function* () { + return Array.from(materialized.values()) + }), + }) + }), +) + +export const locationLayer = layer diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index aea8c0096783..3472cc114a50 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -1,9 +1,8 @@ import { Schema } from "effect" -import { ProviderMetadata } from "@opencode-ai/llm" +import { ProviderMetadata, ToolContent } from "@opencode-ai/llm" import { EventV2 } from "../event" import { ModelV2 } from "../model" import { NonNegativeInt } from "../schema" -import { ToolOutput } from "../tool-output" import { V2Schema } from "../v2-schema" import { FileAttachment, Prompt } from "./prompt" import { SessionSchema } from "./schema" @@ -360,8 +359,8 @@ export namespace Tool { ...options, schema: { ...ToolBase, - structured: ToolOutput.Structured, - content: Schema.Array(ToolOutput.Content), + structured: Schema.Record(Schema.String, Schema.Any), + content: Schema.Array(ToolContent), }, }) export type Progress = typeof Progress.Type @@ -371,8 +370,8 @@ export namespace Tool { ...options, schema: { ...ToolBase, - structured: ToolOutput.Structured, - content: Schema.Array(ToolOutput.Content), + structured: Schema.Record(Schema.String, Schema.Any), + content: Schema.Array(ToolContent), outputPaths: Schema.Array(Schema.String).pipe(Schema.optional), result: Schema.Unknown.pipe(Schema.optional), provider: Schema.Struct({ diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 0d8e9f2a66c8..041c629988a6 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -349,6 +349,5 @@ const toMessage = (input: Admitted) => text: input.prompt.text, files: input.prompt.files, agents: input.prompt.agents, - references: input.prompt.references, time: { created: input.timeCreated }, }) diff --git a/packages/core/src/session/logging.ts b/packages/core/src/session/logging.ts index 947633da7d89..c579ec15dcdc 100644 --- a/packages/core/src/session/logging.ts +++ b/packages/core/src/session/logging.ts @@ -5,4 +5,4 @@ export const logFailure = ( message: "Failed to drain Session" | "Failed to wake Session", sessionID: SessionSchema.ID, cause: Cause.Cause, -) => Effect.logError(message, cause).pipe(Effect.annotateLogs("sessionID", sessionID)) +) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID })) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index bbe1ce757106..cf1eb2cedfda 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -132,7 +132,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { text: event.data.prompt.text, files: event.data.prompt.files, agents: event.data.prompt.agents, - references: event.data.prompt.references, time: { created: event.data.timestamp }, }), ) diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index f6f9fc282649..c5b621a08dab 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -1,9 +1,8 @@ export * as SessionMessage from "./message" import { Schema } from "effect" -import { ProviderMetadata } from "@opencode-ai/llm" +import { ProviderMetadata, ToolContent } from "@opencode-ai/llm" import { ModelV2 } from "../model" -import { ToolOutput } from "../tool-output" import { V2Schema } from "../v2-schema" import { SessionEvent } from "./event" import { Prompt } from "./prompt" @@ -37,7 +36,6 @@ export class User extends Schema.Class("Session.Message.User")({ text: Prompt.fields.text, files: Prompt.fields.files, agents: Prompt.fields.agents, - references: Prompt.fields.references, type: Schema.Literal("user"), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, @@ -77,25 +75,25 @@ export class ToolStatePending extends Schema.Class("Session.Me export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ status: Schema.Literal("running"), input: Schema.Record(Schema.String, Schema.Unknown), - structured: ToolOutput.Structured, - content: ToolOutput.Content.pipe(Schema.Array), + structured: Schema.Record(Schema.String, Schema.Any), + content: ToolContent.pipe(Schema.Array), }) {} export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ status: Schema.Literal("completed"), input: Schema.Record(Schema.String, Schema.Unknown), attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), - content: ToolOutput.Content.pipe(Schema.Array), + content: ToolContent.pipe(Schema.Array), outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths, - structured: ToolOutput.Structured, + structured: Schema.Record(Schema.String, Schema.Any), result: SessionEvent.Tool.Success.data.fields.result, }) {} export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Unknown), - content: ToolOutput.Content.pipe(Schema.Array), - structured: ToolOutput.Structured, + content: ToolContent.pipe(Schema.Array), + structured: Schema.Record(Schema.String, Schema.Any), error: SessionEvent.UnknownError, result: SessionEvent.Tool.Failed.data.fields.result, }) {} diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index e22da3be54d7..caf63de78ac8 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -4,6 +4,7 @@ import { and, desc, eq, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" +import { LayerNode } from "../effect/layer-node" import { SessionEvent } from "./event" import { SessionV1 } from "../v1/session" import { WorkspaceTable } from "../control-plane/workspace.sql" @@ -447,3 +448,4 @@ export const layer = Layer.effectDiscard( ) export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) +export const node = LayerNode.make(layer, [EventV2.node, Database.node]) diff --git a/packages/core/src/session/prompt.ts b/packages/core/src/session/prompt.ts index f1822bcc17d5..a0653c51e606 100644 --- a/packages/core/src/session/prompt.ts +++ b/packages/core/src/session/prompt.ts @@ -29,32 +29,18 @@ export class AgentAttachment extends Schema.Class("Prompt.Agent source: Source.pipe(Schema.optional), }) {} -export class ReferenceAttachment extends Schema.Class("Prompt.ReferenceAttachment")({ - name: Schema.String, - kind: Schema.Literals(["local", "git", "invalid"]), - uri: Schema.String.pipe(Schema.optional), - repository: Schema.String.pipe(Schema.optional), - branch: Schema.String.pipe(Schema.optional), - target: Schema.String.pipe(Schema.optional), - targetUri: Schema.String.pipe(Schema.optional), - problem: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), -}) {} - export class Prompt extends Schema.Class("Prompt")({ text: Schema.String, files: Schema.Array(FileAttachment).pipe(Schema.optional), agents: Schema.Array(AgentAttachment).pipe(Schema.optional), - references: Schema.Array(ReferenceAttachment).pipe(Schema.optional), }) { static readonly equivalence = Schema.toEquivalence(Prompt) - static fromUserMessage(input: Pick) { + static fromUserMessage(input: Pick) { return new Prompt({ text: input.text, ...(input.files === undefined ? {} : { files: input.files }), ...(input.agents === undefined ? {} : { agents: input.agents }), - ...(input.references === undefined ? {} : { references: input.references }), }) } } diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 01a60f9c3aeb..5390a26e3b8f 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -1,11 +1,4 @@ -import { - ToolOutput as LLMToolOutput, - type LLMEvent, - type ProviderMetadata, - type ToolOutput as LLMToolOutputType, - type ToolResultValue, - type Usage, -} from "@opencode-ai/llm" +import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm" import { DateTime, Effect } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" @@ -45,13 +38,13 @@ const message = (value: unknown) => { } } -type ToolOutput = - | { readonly structured: Record; readonly content: LLMToolOutputType["content"] } +type SettledOutput = + | { readonly structured: Record; readonly content: ToolOutput["content"] } | { readonly error: { readonly type: "unknown"; readonly message: string } } -const settledOutput = (value: LLMToolOutputType | undefined, result: ToolResultValue): ToolOutput => { +const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => { if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } } - const settled = value ?? LLMToolOutput.fromResultValue(result) + const settled = value ?? ToolOutput.fromResultValue(result) if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`) return { structured: record(settled.structured), content: settled.content } } diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 1f0d15c1d732..ae36f205b177 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -38,9 +38,8 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => { if (tool.state.status === "completed") { - // TODO: Materialize remote URL and managed file sources before provider-history lowering. - // ToolOutput.toResultValue intentionally rejects unmaterialized sources rather than - // guessing whether a provider can fetch them or leaking host-local resource paths. + // TODO: Materialize remote and managed URIs before provider-history lowering. + // ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes. const result = tool.provider?.executed === true && tool.state.result !== undefined ? tool.state.result @@ -105,7 +104,6 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] metadata: { ...message.metadata, ...(message.agents?.length ? { agents: message.agents } : {}), - ...(message.references?.length ? { references: message.references } : {}), }, }), ] diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index d5f2b9510f44..6402dd3b71a6 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -6,7 +6,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr import { FSUtil } from "../fs-util" import { Global } from "../global" import { AbsolutePath } from "../schema" -import * as Log from "../util/log" const skillConcurrency = 4 const fileConcurrency = 8 @@ -71,7 +70,6 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FSUtil.Service const global = yield* Global.Service - const log = Log.create({ service: "skill-discovery" }) const http = (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ retryOn: "errors-and-responses", @@ -87,7 +85,7 @@ export const layer = Layer.effect( http.execute, Effect.flatMap((response) => response.arrayBuffer), Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))), - Effect.catch((error) => Effect.sync(() => log.error("failed to download skill file", { url, error }))), + Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })), ) }) @@ -100,10 +98,9 @@ export const layer = Layer.effect( HttpClientRequest.acceptJson, http.execute, Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), - Effect.catch((error) => { - log.error("failed to fetch skill index", { url: index, error }) - return Effect.succeed(undefined) - }), + Effect.catch((error) => + Effect.logError("failed to fetch skill index", { url: index, error }).pipe(Effect.as(undefined)), + ), ) if (!data) return [] @@ -111,17 +108,14 @@ export const layer = Layer.effect( return yield* Effect.forEach( data.skills.flatMap((skill) => { if (!isSafeSegment(skill.name)) { - log.warn("skill entry has unsafe name", { url: index, skill: skill.name }) return [] } if (!skill.files.includes("SKILL.md") && !skill.files.includes(`${skill.name}.md`)) { - log.warn("skill entry missing Markdown definition", { url: index, skill: skill.name }) return [] } const root = path.resolve(sourceRoot, skill.name) if (!FSUtil.contains(sourceRoot, root) || root === sourceRoot) { - log.warn("skill entry escapes cache root", { url: index, skill: skill.name }) return [] } @@ -144,7 +138,6 @@ export const layer = Layer.effect( } }) if (files.some((file) => file === undefined)) { - log.warn("skill entry has unsafe file", { url: index, skill: skill.name }) return [] } return [{ skill, root, files: files as { url: string; destination: string }[] }] diff --git a/packages/core/src/tool-output.ts b/packages/core/src/tool-output.ts deleted file mode 100644 index 055d7c248e4d..000000000000 --- a/packages/core/src/tool-output.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * as ToolOutput from "./tool-output" -export { - ToolContent as Content, - ToolFileContent as FileContent, - ToolTextContent as TextContent, - toolFile as file, - toolText as text, -} from "@opencode-ai/llm" -import { Schema } from "effect" - -export const Structured = Schema.Record(Schema.String, Schema.Any) diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 138ecc03d5fb..78e5b0f40d52 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -1,6 +1,6 @@ export * as ApplyPatchTool from "./apply-patch" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" @@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard( "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", input: Input, output: Output, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => { const applied: Array = [] const fail = (path: string) => { diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index 2365bbc3b4f3..bd6f175adae0 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -1,7 +1,7 @@ export * as BashTool from "./bash" import path from "path" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Duration, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" import { Config } from "../config" @@ -119,7 +119,7 @@ export const layer = Layer.effectDiscard( description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, input: Input, output: Output, - toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })], + toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }], execute: (input, context) => Effect.gen(function* () { const source = { diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 5053de670885..62926202dc1b 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -8,6 +8,7 @@ import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { QuestionTool } from "./question" import { ReadTool } from "./read" +import { ReadToolFileSystem } from "./read-filesystem" import { SkillTool } from "./skill" import { TodoWriteTool } from "./todowrite" import { WebFetchTool } from "./webfetch" @@ -34,7 +35,7 @@ export const locationLayer = Layer.mergeAll( GlobTool.layer, GrepTool.layer, QuestionTool.layer, - ReadTool.layer, + ReadTool.layer.pipe(Layer.provide(ReadToolFileSystem.layer)), SkillTool.layer, TodoWriteTool.layer, WebFetchTool.layer, diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index bbd58af8a0a3..9b12704a2234 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -2,12 +2,11 @@ * Model-facing V2 exact-edit leaf. Relative paths resolve within the active * Location. Absolute paths inside that Location are accepted, while explicit * absolute external paths retain mutation capability through a separate - * external_directory approval before edit approval. Named project references - * are read-oriented and deliberately are not accepted by mutation tools. + * external_directory approval before edit approval. */ export * as EditTool from "./edit" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" @@ -21,7 +20,7 @@ export const name = "edit" export const Input = Schema.Struct({ path: Schema.String.annotate({ description: - "File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.", + "File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.", }), oldString: Schema.String.annotate({ description: "Exact text to replace" }), newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }), @@ -100,11 +99,11 @@ export const layer = Layer.effectDiscard( [name]: Tool.withPermission( Tool.make({ description: - "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.", + "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, toModelOutput: ({ input, output }) => [ - toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }), + { type: "text", text: toModelOutput(output, input.oldString, input.newString) }, ], execute: (input, context) => { const unableToEdit = (effect: Effect.Effect) => diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 164604b24ba7..6910cb102fc4 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -1,8 +1,7 @@ export * as GlobTool from "./glob" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" -import { FileSystem } from "../filesystem" import { LocationSearch } from "../location-search" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -15,9 +14,6 @@ export const Input = Schema.Struct({ path: LocationSearch.FilesInput.fields.path.annotate({ description: "Relative directory to search. Defaults to the active Location.", }), - reference: LocationSearch.FilesInput.fields.reference.annotate({ - description: "Named project reference to search instead of the active Location", - }), limit: LocationSearch.FilesInput.fields.limit.annotate({ description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`, }), @@ -41,13 +37,10 @@ export const toModelOutput = (output: ModelOutput) => { /** * Location-scoped glob leaf. FileSystem supplies canonical permission metadata; * LocationSearch resolves the current root and owns containment and traversal. - * - * TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules. */ export const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service - const filesystem = yield* FileSystem.Service const search = yield* LocationSearch.Service const permission = yield* PermissionV2.Service @@ -55,20 +48,18 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description: - "Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", + "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", input: Input, output: LocationSearch.FilesResult, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => Effect.gen(function* () { - const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference }) yield* permission.assert({ action: name, resources: [input.pattern], save: ["*"], metadata: { - root: root.resource, - reference: input.reference, + root: input.path ?? ".", path: input.path, limit: input.limit, }, diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 41483662ee23..86f28e977a7a 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -1,8 +1,7 @@ export * as GrepTool from "./grep" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" -import { FileSystem } from "../filesystem" import { LocationSearch } from "../location-search" import { Ripgrep } from "../ripgrep" import { PermissionV2 } from "../permission" @@ -18,9 +17,6 @@ export const Input = Schema.Struct({ path: LocationSearch.GrepInput.fields.path.annotate({ description: "Relative file or directory to search. Defaults to the active Location.", }), - reference: LocationSearch.GrepInput.fields.reference.annotate({ - description: "Named project reference to search instead of the active Location", - }), include: LocationSearch.GrepInput.fields.include.annotate({ description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")', }), @@ -56,13 +52,10 @@ export const toModelOutput = (output: Output) => { /** * Location-scoped grep leaf. FileSystem supplies canonical permission metadata; * LocationSearch resolves the current root and owns containment and ripgrep execution. - * - * TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules. */ export const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service - const filesystem = yield* FileSystem.Service const search = yield* LocationSearch.Service const permission = yield* PermissionV2.Service @@ -70,20 +63,18 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description: - "Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", + "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", input: Input, output: LocationSearch.GrepResult, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => Effect.gen(function* () { - const root = yield* filesystem.resolveRoot(input) yield* permission.assert({ action: name, resources: [input.pattern], save: ["*"], metadata: { - root: root.resource, - reference: input.reference, + root: input.path ?? ".", path: input.path, include: input.include, limit: input.limit, diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 7422e2f0b7e2..6c50a809eccb 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -1,6 +1,6 @@ export * as QuestionTool from "./question" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { PermissionV2 } from "../permission" import { QuestionV2 } from "../question" @@ -55,7 +55,7 @@ export const layer = Layer.effectDiscard( input: Input, output: Output, toModelOutput: ({ input, output }) => [ - toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }), + { type: "text", text: toModelOutput(input.questions, output.answers) }, ], execute: (input, context) => permission diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts new file mode 100644 index 000000000000..fcfe2f4c9426 --- /dev/null +++ b/packages/core/src/tool/read-filesystem.ts @@ -0,0 +1,305 @@ +export * as ReadToolFileSystem from "./read-filesystem" + +import path from "path" +import { pathToFileURL } from "url" +import { Context, Effect, Layer, Option, Schema } from "effect" +import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" +import { AbsolutePath, PositiveInt, RelativePath } from "../schema" + +export const MAX_READ_LINES = 2_000 +export const MAX_READ_BYTES = 50 * 1024 +export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024 +const MAX_LINE_LENGTH = 2_000 +const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` + +export class BinaryFileError extends Error { + constructor(readonly resource: string) { + super(`Cannot read binary file: ${resource}`) + this.name = "BinaryFileError" + } +} + +export class MediaIngestLimitError extends Error { + constructor( + readonly resource: string, + readonly maximumBytes: number, + ) { + super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`) + this.name = "MediaIngestLimitError" + } +} + +export const PageInput = Schema.Struct({ + offset: PositiveInt.pipe(Schema.optional), + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), +}) +export type PageInput = typeof PageInput.Type + +export class TextPage extends Schema.Class("ReadTool.TextPage")({ + type: Schema.Literal("text-page"), + content: Schema.String, + mime: Schema.String, + offset: PositiveInt, + truncated: Schema.Boolean, + next: PositiveInt.pipe(Schema.optional), +}) {} + +export class ListPage extends Schema.Class("ReadTool.ListPage")({ + entries: Schema.Array(FileSystem.Entry), + truncated: Schema.Boolean, + next: PositiveInt.pipe(Schema.optional), +}) {} + +export interface Interface { + readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory"> + readonly read: ( + path: AbsolutePath, + resource: string, + page?: PageInput, + ) => Effect.Effect + readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/ReadToolFileSystem") {} + +const extensions = new Set([ + ".zip", + ".tar", + ".gz", + ".exe", + ".dll", + ".so", + ".class", + ".jar", + ".war", + ".7z", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".ppt", + ".pptx", + ".odt", + ".ods", + ".odp", + ".bin", + ".dat", + ".obj", + ".o", + ".a", + ".lib", + ".wasm", + ".pyc", + ".pyo", +]) +const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) +const imageMime = (bytes: Uint8Array) => { + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" + if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" + if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) + return "image/webp" +} +const binary = (resource: string, bytes: Uint8Array) => { + if (extensions.has(path.extname(resource).toLowerCase())) return true + if (bytes.length === 0) return false + let nonPrintable = 0 + for (const byte of bytes) { + if (byte === 0) return true + if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++ + } + return nonPrintable / bytes.length > 0.3 +} + +export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) { + const info = yield* fs.stat(input).pipe(Effect.orDie) + const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined + if (!type) return yield* Effect.die(new Error("Path is not a file or directory")) + return type +}) + +export const read = Effect.fn("ReadTool.read")(function* ( + fs: FSUtil.Interface, + input: string, + resource: string, + page: PageInput = {}, +) { + const real = yield* fs.realPath(input).pipe(Effect.orDie) + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie) + const info = yield* file.stat.pipe(Effect.orDie) + if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + const first = Option.getOrElse( + yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie), + () => new Uint8Array(), + ) + const mime = imageMime(first) + if (mime) { + if (info.size > MAX_MEDIA_INGEST_BYTES) + return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + const chunks = [first] + let total = first.length + while (total <= MAX_MEDIA_INGEST_BYTES) { + const chunk = yield* file + .readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)) + .pipe(Effect.orDie) + if (Option.isNone(chunk)) break + chunks.push(chunk.value) + total += chunk.value.length + } + if (total > MAX_MEDIA_INGEST_BYTES) + return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES)) + return { + uri: pathToFileURL(real).href, + name: path.basename(real), + content: Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk)), + total, + ).toString("base64"), + encoding: "base64" as const, + mime, + } + } + if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first)) + return yield* Effect.die(new BinaryFileError(resource)) + const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined + if (!paged) { + const decoder = new TextDecoder("utf-8", { fatal: true }) + const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))] + while (true) { + const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + if (Option.isNone(chunk)) break + if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource)) + text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true }))) + } + text.push(yield* Effect.sync(() => decoder.decode())) + return { + uri: pathToFileURL(real).href, + name: path.basename(real), + content: text.join(""), + encoding: "utf8" as const, + mime: FSUtil.mimeType(real), + } + } + const offset = page.offset ?? 1 + const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) + const lines: string[] = [] + const decoder = new TextDecoder("utf-8", { fatal: true }) + let pending = "" + let discard = false + let line = 1 + let bytes = 0 + let found = false + let truncated = false + let next: number | undefined + const append = (input: string) => { + if (line < offset) { + line++ + return + } + if (lines.length >= limit || bytes >= MAX_READ_BYTES) { + truncated = true + next ??= line++ + return + } + found = true + const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input + const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) + if (bytes + size > MAX_READ_BYTES) { + truncated = true + next ??= line++ + return + } + lines.push(text) + bytes += size + line++ + } + const consume = (chunk: Uint8Array) => { + if (chunk.includes(0)) throw new BinaryFileError(resource) + let text = decoder.decode(chunk, { stream: true }) + while (true) { + const index = text.indexOf("\n") + if (index === -1) { + if (!discard) { + pending += text + if (pending.length > MAX_LINE_LENGTH) { + pending = pending.slice(0, MAX_LINE_LENGTH + 1) + discard = true + } + } + break + } + const current = pending + (discard ? "" : text.slice(0, index)) + pending = "" + discard = false + text = text.slice(index + 1) + append(current.endsWith("\r") ? current.slice(0, -1) : current) + } + } + yield* Effect.sync(() => consume(first)) + while (true) { + const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + if (Option.isNone(chunk)) break + yield* Effect.sync(() => consume(chunk.value)) + } + const tail = yield* Effect.sync(() => decoder.decode()) + if (!discard) pending += tail + if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending) + if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`)) + return new TextPage({ + type: "text-page", + content: lines.join("\n"), + mime: FSUtil.mimeType(real), + offset, + truncated, + ...(next === undefined ? {} : { next }), + }) + }), + ) +}) + +export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { + const real = yield* fs.realPath(input).pipe(Effect.orDie) + const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie) + const offset = page.offset ?? 1 + const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) + const entries = yield* Effect.forEach( + items, + (item) => + Effect.gen(function* () { + const absolute = path.join(real, item.name) + const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void)) + if (!target || !FSUtil.contains(real, target)) return + const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void)) + const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined + if (!type) return + return new FileSystem.Entry({ + path: RelativePath.make(item.name), + uri: pathToFileURL(target).href, + type, + mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target), + }) + }), + { concurrency: 16 }, + ) + const visible = entries + .filter((item): item is FileSystem.Entry => item !== undefined) + .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)) + const selected = visible.slice(offset - 1, offset - 1 + limit) + const truncated = offset - 1 + selected.length < visible.length + return new ListPage({ entries: selected, truncated, ...(truncated ? { next: offset + selected.length } : {}) }) +}) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + return Service.of({ + inspect: (path) => inspect(fs, path), + read: (path, resource, page) => read(fs, path, resource, page), + list: (path, page) => list(fs, path, page), + }) + }), +) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 45045ad8a1c4..64f02d813fe3 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -1,31 +1,38 @@ export * as ReadTool from "./read" import { ToolFailure } from "@opencode-ai/llm" +import path from "path" import { Effect, Layer, Schema } from "effect" import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" import { Image } from "../image" +import { Location } from "../location" import { PermissionV2 } from "../permission" +import { AbsolutePath } from "../schema" +import { ReadToolFileSystem } from "./read-filesystem" import { Tool } from "./tool" import { Tools } from "./tools" export const name = "read" const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) const LocationInput = Schema.Struct({ - ...FileSystem.ReadInput.fields, - offset: FileSystem.ListPageInput.fields.offset.annotate({ + path: Schema.String, + offset: ReadToolFileSystem.PageInput.fields.offset.annotate({ description: "The 1-based directory entry or text line offset to start reading from", }), - limit: FileSystem.ListPageInput.fields.limit.annotate({ + limit: ReadToolFileSystem.PageInput.fields.limit.annotate({ description: "The maximum number of directory entries or text lines to read", }), }) const Input = LocationInput -const Output = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage]) +const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage]) export const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service - const filesystem = yield* FileSystem.Service + const fs = yield* FSUtil.Service + const reader = yield* ReadToolFileSystem.Service + const location = yield* Location.Service const image = yield* Image.Service const permission = yield* PermissionV2.Service @@ -33,11 +40,12 @@ export const layer = Layer.effectDiscard( .register({ [name]: Tool.make({ description: - "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.", + "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.", input: Input, output: Output, toModelOutput: ({ input, output }) => { - if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return [] + if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) + return [] return [ { type: "text", text: "Image read successfully" }, { type: "file", data: output.content, mime: output.mime, name: input.path }, @@ -45,33 +53,43 @@ export const layer = Layer.effectDiscard( }, execute: (input, context) => { return Effect.gen(function* () { - const resolved = yield* filesystem.resolveReadPath(input) + const absolute = path.resolve(location.directory, input.path) + const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory + if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute)) + return yield* Effect.die(new Error("Path escapes the allowed read root")) + const real = yield* fs.realPath(absolute).pipe(Effect.orDie) + const root = yield* fs.realPath(selected).pipe(Effect.orDie) + if (!FSUtil.contains(root, real)) + return yield* Effect.die(new Error("Path escapes the allowed read root")) + const resource = path.relative(root, real).replaceAll("\\", "/") || "." + const target = AbsolutePath.make(real) + const type = yield* reader.inspect(target) yield* permission.assert({ action: name, - resources: [resolved.resource], + resources: [resource], save: ["*"], sessionID: context.sessionID, agent: context.agent, source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, }) - if (resolved.type === "directory") return yield* filesystem.listPage(input) - const content = yield* filesystem.readTool(input, { + if (type === "directory") return yield* reader.list(target, { offset: input.offset, limit: input.limit }) + const content = yield* reader.read(target, resource, { offset: input.offset, limit: input.limit, }) - if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { + if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { return yield* image - .normalize(resolved.resource, content) + .normalize(resource, { ...content, encoding: "base64" }) .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content))) } - if (content.type === "binary") - return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource)) + if ("encoding" in content && content.encoding === "base64") + return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource)) return content }).pipe( Effect.mapError((error) => { const message = - error instanceof FileSystem.BinaryFileError || - error instanceof FileSystem.MediaIngestLimitError || + error instanceof ReadToolFileSystem.BinaryFileError || + error instanceof ReadToolFileSystem.MediaIngestLimitError || error instanceof Image.DecodeError || error instanceof Image.SizeError ? error.message diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 362a83b82973..d99cc9014c41 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -1,6 +1,6 @@ export * as ToolRegistry from "./registry" -import { ToolOutput, type ToolCall, type ToolDefinition, type ToolSettlement } from "@opencode-ai/llm" +import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm" import { Context, Effect, Layer, Scope } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" @@ -30,7 +30,9 @@ export interface Materialization { readonly settle: (input: ExecuteInput) => Effect.Effect } -export interface Settlement extends ToolSettlement { +export interface Settlement { + readonly result: ToolResultValue + readonly output?: ToolOutput readonly outputPaths?: ReadonlyArray } diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 1577d81237af..589a99d46227 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -2,7 +2,7 @@ export * as SkillTool from "./skill" import path from "path" import { pathToFileURL } from "url" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { FSUtil } from "../fs-util" import { PluginBoot } from "../plugin/boot" @@ -68,7 +68,7 @@ export const layer = Layer.effectDiscard( description, input: Input, output: Output, - toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], + toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { const current = yield* skills.list() diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index 7471771ef8bb..a746d524f0ec 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -1,6 +1,6 @@ export * as TodoWriteTool from "./todowrite" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { PermissionV2 } from "../permission" import { SessionTodo } from "../session/todo" @@ -33,7 +33,7 @@ export const layer = Layer.effectDiscard( "Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.", input: Input, output: Output, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => Effect.gen(function* () { yield* permission.assert({ diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 5ffab7ec8f4d..eb70f2cb474e 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -92,21 +92,20 @@ export function make, Output extends SchemaType - ToolOutput.make( - output, + Effect.map((output) => ({ + structured: output, + content: config.toModelOutput?.({ input, output }).map((part) => part.type === "text" ? { type: "text" as const, text: part.text } : { type: "file" as const, - source: { type: "data" as const, data: part.data }, + uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name, }, - ) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []), - ), - ), + ) ?? (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), + })), ), ), ), diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 612d69a08f93..1e209e500861 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,6 +1,6 @@ export * as WebFetchTool from "./webfetch" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Duration, Effect, Layer, Schema, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Parser } from "htmlparser2" @@ -136,7 +136,7 @@ export const layer = Layer.effectDiscard( description, input: Input, output: Output, - toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], + toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { yield* Effect.try({ diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index a8f2e2dd4e5f..cea19c17e8f3 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -1,6 +1,6 @@ export * as WebSearchTool from "./websearch" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { truthy } from "../flag/flag" @@ -194,7 +194,7 @@ export const layer = Layer.effectDiscard( description, input: Input, output: Output, - toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })], + toModelOutput: ({ output }) => [{ type: "text", text: output.text }], execute: (input, context) => { const provider = selectProvider(context.sessionID, config, config.provider) return Effect.gen(function* () { diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 350d187ed0a7..1438e52247fe 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -2,12 +2,11 @@ * Model-facing V2 file-write leaf. Relative paths resolve within the active * Location. Absolute paths inside that Location are accepted, while explicit * absolute external paths retain mutation capability through a separate - * external_directory approval before edit approval. Named project references - * are read-oriented and deliberately are not accepted by mutation tools. + * external_directory approval before edit approval. */ export * as WriteTool from "./write" -import { ToolFailure, toolText } from "@opencode-ai/llm" +import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { LocationMutation } from "../location-mutation" @@ -21,7 +20,7 @@ export const name = "write" export const Input = Schema.Struct({ path: Schema.String.annotate({ description: - "File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.", + "File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.", }), content: Schema.String.annotate({ description: "Content to write to the file" }), }) @@ -55,10 +54,10 @@ export const layer = Layer.effectDiscard( [name]: Tool.withPermission( Tool.make({ description: - "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.", + "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, - toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })], + toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => Effect.gen(function* () { const source = { diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index 64a1b6f7ad94..2ba5ef0d759d 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -6,6 +6,7 @@ import type { FileSystem, Scope } from "effect" import type { PlatformError } from "effect/PlatformError" import { FSUtil } from "../fs-util" import { Global } from "../global" +import { LayerNode } from "../effect/layer-node" import { Hash } from "./hash" export namespace EffectFlock { @@ -280,4 +281,5 @@ export namespace EffectFlock { ) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer)) + export const node = LayerNode.make(layer, [Global.node, FSUtil.node]) } diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts deleted file mode 100644 index c395ac017552..000000000000 --- a/packages/core/src/util/log.ts +++ /dev/null @@ -1,197 +0,0 @@ -export * as Log from "./log" - -import path from "path" -import fs from "fs/promises" -import { createWriteStream } from "fs" -import * as Global from "../global" -import { Schema } from "effect" -import { Glob } from "./glob" - -export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({ - identifier: "LogLevel", - description: "Log level", -}) -export type Level = Schema.Schema.Type - -const levelPriority: Record = { - DEBUG: 0, - INFO: 1, - WARN: 2, - ERROR: 3, -} -const keep = 10 -const initializedRunID = "OPENCODE_LOG_INITIALIZED_RUN_ID" - -let level: Level = "INFO" - -function shouldLog(input: Level): boolean { - return levelPriority[input] >= levelPriority[level] -} - -export type Logger = { - debug(message?: any, extra?: Record): void - info(message?: any, extra?: Record): void - error(message?: any, extra?: Record): void - warn(message?: any, extra?: Record): void - tag(key: string, value: string): Logger - clone(): Logger - time( - message: string, - extra?: Record, - ): { - stop(): void - [Symbol.dispose](): void - } -} - -const loggers = new Map() - -export const Default = create({ service: "default" }) - -export interface Options { - print: boolean - dev?: boolean - level?: Level -} - -let logpath = "" -export function file() { - return logpath -} -export function getLevel(): Level { - return level -} -let write = (msg: any) => { - process.stderr.write(msg) - return msg.length -} - -export async function init(options: Options) { - if (options.level) level = options.level - void cleanup(Global.Path.log) - if (options.print) return - logpath = path.join( - Global.Path.log, - options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log", - ) - const runID = process.env.OPENCODE_RUN_ID - const shouldTruncate = !options.dev || !runID || process.env[initializedRunID] !== runID - if (shouldTruncate) await fs.truncate(logpath).catch(() => {}) - if (options.dev && runID) process.env[initializedRunID] = runID - const stream = createWriteStream(logpath, { flags: "a" }) - write = async (msg: any) => { - return new Promise((resolve, reject) => { - stream.write(msg, (err) => { - if (err) reject(err) - else resolve(msg.length) - }) - }) - } -} - -async function cleanup(dir: string) { - const files = ( - await Glob.scan("????-??-??T??????.log", { - cwd: dir, - absolute: false, - include: "file", - }).catch(() => []) - ) - .filter((file) => path.basename(file) === file) - .sort() - if (files.length <= keep) return - - const doomed = files.slice(0, -keep) - await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {}))) -} - -function formatError(error: Error, depth = 0): string { - const result = error.message - return error.cause instanceof Error && depth < 10 - ? result + " Caused by: " + formatError(error.cause, depth + 1) - : result -} - -let last = Date.now() -export function create(tags?: Record) { - tags = tags || {} - - const service = tags["service"] - if (service && typeof service === "string") { - const cached = loggers.get(service) - if (cached) { - return cached - } - } - - function build(message: any, extra?: Record) { - const prefix = Object.entries({ - ...tags, - ...extra, - }) - .filter(([_, value]) => value !== undefined && value !== null) - .map(([key, value]) => { - const prefix = `${key}=` - if (value instanceof Error) return prefix + formatError(value) - if (typeof value === "object") return prefix + JSON.stringify(value) - return prefix + value - }) - .join(" ") - const next = new Date() - const diff = next.getTime() - last - last = next.getTime() - return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n" - } - const result: Logger = { - debug(message?: any, extra?: Record) { - if (shouldLog("DEBUG")) { - write("DEBUG " + build(message, extra)) - } - }, - info(message?: any, extra?: Record) { - if (shouldLog("INFO")) { - write("INFO " + build(message, extra)) - } - }, - error(message?: any, extra?: Record) { - if (shouldLog("ERROR")) { - write("ERROR " + build(message, extra)) - } - }, - warn(message?: any, extra?: Record) { - if (shouldLog("WARN")) { - write("WARN " + build(message, extra)) - } - }, - tag(key: string, value: string) { - if (tags) tags[key] = value - return result - }, - clone() { - return create({ ...tags }) - }, - time(message: string, extra?: Record) { - const now = Date.now() - result.info(message, { status: "started", ...extra }) - function stop() { - result.info(message, { - status: "completed", - duration: Date.now() - now, - ...extra, - }) - } - return { - stop, - [Symbol.dispose]() { - stop() - }, - } - }, - } - - if (service && typeof service === "string") { - loggers.set(service, result) - } - - return result -} diff --git a/packages/core/src/util/opencode-process.ts b/packages/core/src/util/opencode-process.ts deleted file mode 100644 index f59270ad2d5d..000000000000 --- a/packages/core/src/util/opencode-process.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID" -export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE" - -export function ensureRunID() { - return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID()) -} - -export function ensureProcessRole(fallback: "main" | "worker") { - return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback) -} - -export function ensureProcessMetadata(fallback: "main" | "worker") { - return { - runID: ensureRunID(), - processRole: ensureProcessRole(fallback), - } -} - -export function sanitizedProcessEnv(overrides?: Record) { - const env = Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ) - return overrides ? Object.assign(env, overrides) : env -} diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index f85175cb7909..cea9d3454b42 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -43,7 +43,7 @@ export const Info = Schema.Struct({ }), skills: Schema.optional(ConfigSkillsV1.Info).annotate({ description: "Additional skill folder paths" }), reference: Schema.optional(ConfigReferenceV1.Info).annotate({ - description: "Named git or local directory references that can be mentioned as @alias or @alias/path", + description: "Named git or local directory references", }), watcher: Schema.optional(Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))) })), snapshot: Schema.optional(Schema.Boolean).annotate({ diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index f6cae7d78310..d54a3f08f926 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -18,7 +18,7 @@ export const Model = Schema.Struct({ Schema.Union([ Schema.Literal(true), Schema.Struct({ - field: Schema.Literals(["reasoning_content", "reasoning_details"]), + field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), }), ]), ), diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index 1d0f54120c91..38b0c9570487 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -63,7 +63,7 @@ describe("ApplicationTools", () => { type: "content", value: [ { type: "text", text: "ONCE" }, - { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" }, + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" }, ], }) expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }]) @@ -132,14 +132,14 @@ describe("ApplicationTools", () => { type: "content", value: [ { type: "text", text: "HELLO" }, - { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" }, + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" }, ], }, output: { structured: { answer: "HELLO" }, content: [ { type: "text", text: "HELLO" }, - { type: "file", source: { type: "data", data: "aGVsbG8=" }, mime: "image/png", name: "result.png" }, + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" }, ], }, }) diff --git a/packages/core/test/effect/observability.test.ts b/packages/core/test/effect/observability.test.ts index 50ea23f89463..4758563f287b 100644 --- a/packages/core/test/effect/observability.test.ts +++ b/packages/core/test/effect/observability.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" -import { resource } from "@opencode-ai/core/effect/observability" +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Logger } from "effect" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { fileLogger } from "../../src/observability/logging" +import { resource } from "../../src/observability/otlp" const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES const opencodeClient = process.env.OPENCODE_CLIENT @@ -42,5 +48,62 @@ describe("resource", () => { "service.namespace": "anomalyco", }) expect(resource().attributes["service.instance.id"]).not.toBe("override") + expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/) }) }) + +test("file logger appends concurrent runs with a run on every line", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-")) + await using _ = { + async [Symbol.asyncDispose]() { + await fs.rm(dir, { recursive: true, force: true }) + }, + } + const file = path.join(dir, "opencode.log") + const write = (runID: string) => + Effect.forEach( + Array.from({ length: 50 }, (_, index) => index), + (index) => Effect.logInfo(`entry-${index}`), + ).pipe( + Effect.provide(Logger.layer([fileLogger(file, runID)]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)), + Effect.scoped, + ) + + await Effect.runPromise(Effect.all([write("run-a"), write("run-b")], { concurrency: "unbounded" })) + + const lines = (await Bun.file(file).text()).trim().split("\n") + expect(lines).toHaveLength(100) + expect(lines.filter((line) => line.includes("run=run-a"))).toHaveLength(50) + expect(lines.filter((line) => line.includes("run=run-b"))).toHaveLength(50) + expect(lines.every((line) => line.startsWith("timestamp=") && line.includes(" level=INFO "))).toBe(true) + expect(lines.every((line) => !line.includes(" fiber="))).toBe(true) + expect(lines.every((line) => !line.startsWith("{"))).toBe(true) +}) + +test("file logger flattens nested objects", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-")) + await using _ = { + async [Symbol.asyncDispose]() { + await fs.rm(dir, { recursive: true, force: true }) + }, + } + const file = path.join(dir, "opencode.log") + + await Effect.logInfo("request complete", { + request: { method: "GET", timing: { duration: 42 } }, + tags: ["api", "test"], + }).pipe( + Effect.annotateLogs({ session: { id: "session-1" } }), + Effect.provide(Logger.layer([fileLogger(file, "run-a")]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)), + Effect.scoped, + Effect.runPromise, + ) + + const line = (await Bun.file(file).text()).trim() + expect(line).toContain('message="request complete"') + expect(line).toContain("request.method=GET") + expect(line).toContain("request.timing.duration=42") + expect(line).toContain('tags="[\\\"api\\\",\\\"test\\\"]"') + expect(line).toContain("session.id=session-1") + expect(line).not.toContain("request={") +}) diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 10dad732a8ac..3f8f49009aa9 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -19,6 +19,8 @@ const tmpdir = (init?: (dir: string) => Effect.Effect) => ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void)) const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data)) +const waitForFileIndex = (search: Search.Interface, cwd: string) => + search.glob({ cwd, pattern: "**/*", limit: 1 }).pipe(Effect.ignore) describe("file.search", () => { it.live("uses fff for Bun-backed grep", () => @@ -43,9 +45,10 @@ describe("file.search", () => { yield* write(path.join(dir, "README.md"), "hello\n") const search = yield* Search.Service + yield* waitForFileIndex(search, dir) const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 }) - expect(results).toContain("README.md") + expect(results).toContainEqual({ path: "README.md", type: "file" }) }), ) @@ -57,11 +60,30 @@ describe("file.search", () => { yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n") const search = yield* Search.Service + yield* waitForFileIndex(search, dir) const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" }) - expect(results).toContain("README.md") - expect(results).toContain("src/") - expect(results).not.toContain("") + expect(results).toContainEqual({ path: "README.md", type: "file" }) + expect(results).toContainEqual({ path: "src/", type: "directory" }) + expect(results.map((item) => item.path)).not.toContain("") + }), + ) + + it.live("stabilizes equal score file candidates by path length", () => + Effect.gen(function* () { + expect(Fff.available()).toBe(true) + const dir = yield* tmpdir() + yield* write(path.join(dir, "src", "longer-name.ts"), "export const longer = true\n") + yield* write(path.join(dir, "a.ts"), "export const shorter = true\n") + + const search = yield* Search.Service + yield* waitForFileIndex(search, dir) + const results = yield* search.file({ cwd: dir, query: "", limit: 10 }) + + expect(results.slice(0, 2)).toEqual([ + { path: "a.ts", type: "file" }, + { path: "src/longer-name.ts", type: "file" }, + ]) }), ) @@ -142,8 +164,9 @@ describe("file.search", () => { yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n") const search = yield* Search.Service + yield* waitForFileIndex(search, dir) const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 }) - expect(results).toContain("alpha-target-two.ts") + expect(results).toContainEqual({ path: "alpha-target-two.ts", type: "file" }) // open() records the query->file association in fff's history db via the // live picker. It must resolve a remembered file and run without error. diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index 56d2fa3dfb5f..cfbe58dbd857 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -1,43 +1,25 @@ import fs from "fs/promises" import path from "path" import { fileURLToPath } from "url" -import { describe, expect, test } from "bun:test" -import { Effect, Exit, Layer, Schema } from "effect" +import { describe, expect } from "bun:test" +import { Effect, Exit, Layer } from "effect" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Search } from "@opencode-ai/core/filesystem/search" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" -import { FileSystem } from "@opencode-ai/core/filesystem" -import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" -import { ProjectReference } from "@opencode-ai/core/project-reference" -import { Repository } from "@opencode-ai/core/repository" -import { Global } from "@opencode-ai/core/global" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" -import { tmpdir } from "./fixture/tmpdir" import { location } from "./fixture/location" +import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" -const inertReferences = ProjectReference.Service.of({ - list: () => Effect.succeed([]), - get: () => Effect.succeed(undefined), - resolveMention: () => Effect.succeed(undefined), - ensurePath: () => Effect.void, - containsManagedPath: () => Effect.succeed(false), -}) - -function provide( - directory: string, - references = inertReferences, - filesystem = FSUtil.defaultLayer, - data = Global.Path.data, -) { +function provide(directory: string, search = Search.defaultLayer) { return Effect.provide( FileSystem.layer.pipe( Layer.provide( Layer.mergeAll( - filesystem, - Ripgrep.defaultLayer, + FSUtil.defaultLayer, + search, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - Layer.succeed(ProjectReference.Service, references), - Global.layerWith({ data }), ), ), ), @@ -52,235 +34,44 @@ function withTmp(f: (directory: string) => Effect.Effect) { } describe("FileSystem", () => { - it.live("accepts generated managed output paths and rejects other absolute paths", () => - withTmp((directory) => { - const worktree = directory - const data = path.join(directory, "data") - return Effect.gen(function* () { - const managed = path.join(data, "tool-output") - const output = path.join(managed, "tool_123") - const unrelated = path.join(directory, "secret.txt") - yield* Effect.promise(() => fs.mkdir(managed, { recursive: true })) - yield* Effect.promise(() => fs.writeFile(output, "failure here")) - yield* Effect.promise(() => fs.writeFile(unrelated, "secret")) - const service = yield* FileSystem.Service - - expect(yield* service.read({ path: output })).toMatchObject({ type: "text", content: "failure here" }) - expect((yield* service.resolveRoot({ path: output })).real).toBe(output) - expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" }) - expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" }) - }).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data)) - }), - ) - - it.live("reads text and binary files", () => + it.live("reads complete text and binary files", () => withTmp((directory) => Effect.gen(function* () { - yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello")) + const text = Array.from({ length: 3_000 }, (_, index) => `line-${index + 1}`).join("\n") + yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), text)) yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2]))) const service = yield* FileSystem.Service - - expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({ - type: "text", - content: "hello", + const textContent = yield* service.read({ path: RelativePath.make("large.txt") }) + expect(textContent).toEqual({ + uri: textContent.uri, + name: "large.txt", + content: text, + encoding: "utf8", mime: "text/plain", }) - expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({ - type: "binary", + expect(fileURLToPath(textContent.uri)).toBe(path.join(directory, "large.txt")) + const binaryContent = yield* service.read({ path: RelativePath.make("data.bin") }) + expect(binaryContent).toEqual({ + uri: binaryContent.uri, + name: "data.bin", content: "AAEC", encoding: "base64", mime: "application/octet-stream", }) - expect(Exit.isFailure(yield* service.readTool({ path: RelativePath.make("data.bin") }).pipe(Effect.exit))).toBe( - true, - ) - }).pipe(provide(directory)), - ), - ) - - it.live("pages large UTF-8 text files by line with continuation", () => - withTmp((directory) => - Effect.gen(function* () { - const lines = Array.from({ length: 30 }, (_, index) => `line-${index + 1}-é`.padEnd(2_000, "x")) - yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), lines.join("\n"))) - const service = yield* FileSystem.Service - const input = { path: RelativePath.make("large.txt") } - - const result = yield* service.readTool(input) - expect(result).toMatchObject({ - type: "text-page", - offset: 1, - truncated: true, - }) - const first = result.type === "text-page" ? result : yield* Effect.die(new Error("Expected a text page")) - expect(first.next).toBeDefined() - const next = first.next! - expect(yield* service.readTool(input, { offset: next, limit: 1 })).toEqual({ - type: "text-page", - content: lines[next - 1], - mime: "text/plain", - offset: next, - truncated: true, - next: next + 1, - }) - expect(yield* service.readTool(input, { offset: 30 })).toEqual({ - type: "text-page", - content: lines[29], - mime: "text/plain", - offset: 30, - truncated: false, - }) - }).pipe(provide(directory)), - ), - ) - - it.live("rejects paged text when a late NUL appears after the requested page", () => - withTmp((directory) => - Effect.gen(function* () { - const file = path.join(directory, "late-binary.txt") - yield* Effect.promise(() => - fs.writeFile( - file, - Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0])]), - ), - ) - const service = yield* FileSystem.Service - expect( - Exit.isFailure( - yield* service.readTool({ path: RelativePath.make("late-binary.txt") }, { limit: 1 }).pipe(Effect.exit), - ), - ).toBe(true) - }).pipe(provide(directory)), - ), - ) - - it.live("rejects paged text when invalid UTF-8 appears near EOF", () => - withTmp((directory) => - Effect.gen(function* () { - const file = path.join(directory, "invalid-utf8.txt") - yield* Effect.promise(() => - fs.writeFile( - file, - Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0xc3, 0x28])]), - ), - ) - const service = yield* FileSystem.Service - expect( - Exit.isFailure( - yield* service.readTool({ path: RelativePath.make("invalid-utf8.txt") }, { limit: 1 }).pipe(Effect.exit), - ), - ).toBe(true) - }).pipe(provide(directory)), - ), - ) - - it.live("rejects PDFs for direct, large, and paged reads", () => - withTmp((directory) => - Effect.gen(function* () { - const small = path.join(directory, "small.pdf") - const large = path.join(directory, "large.pdf") - yield* Effect.promise(() => fs.writeFile(small, "%PDF-1.7\nsmall")) - yield* Effect.promise(() => - fs.writeFile(large, Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(80_000)])), - ) - const service = yield* FileSystem.Service - expect( - Exit.isFailure(yield* service.readTool({ path: RelativePath.make("small.pdf") }).pipe(Effect.exit)), - ).toBe(true) - expect( - Exit.isFailure(yield* service.readTool({ path: RelativePath.make("large.pdf") }).pipe(Effect.exit)), - ).toBe(true) - expect( - Exit.isFailure( - yield* service.readTool({ path: RelativePath.make("large.pdf") }, { limit: 1 }).pipe(Effect.exit), - ), - ).toBe(true) - }).pipe(provide(directory)), - ), - ) - - it.live("rejects signature-bearing media beyond the ingestion cap before loading", () => - withTmp((directory) => - Effect.gen(function* () { - const file = path.join(directory, "huge.png") - yield* Effect.promise(async () => { - const handle = await fs.open(file, "w") - try { - await handle.write(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 0, 8, 0) - await handle.truncate(FileSystem.MAX_MEDIA_INGEST_BYTES + 1) - } finally { - await handle.close() - } - }) - const service = yield* FileSystem.Service - const exit = yield* service.readTool({ path: RelativePath.make("huge.png") }).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("Media exceeds") + expect(fileURLToPath(binaryContent.uri)).toBe(path.join(directory, "data.bin")) }).pipe(provide(directory)), ), ) - it.live("closes descriptors after successful and failed reads", () => - withTmp((directory) => { - let active = 0 - const filesystem = Layer.effect( - FSUtil.Service, - Effect.gen(function* () { - const service = yield* FSUtil.Service - return FSUtil.Service.of({ - ...service, - open: (target, options) => - Effect.acquireRelease( - service.open(target, options).pipe(Effect.tap(() => Effect.sync(() => active++))), - () => Effect.sync(() => active--), - ), - }) - }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) - return Effect.gen(function* () { - const text = path.join(directory, "text.txt") - const binary = path.join(directory, "binary.pdf") - yield* Effect.promise(() => fs.writeFile(text, "hello")) - yield* Effect.promise(() => fs.writeFile(binary, "%PDF-1.7")) - const service = yield* FileSystem.Service - const before = - process.platform === "win32" - ? undefined - : yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length)) - for (let index = 0; index < 50; index++) { - yield* service.readTool({ path: RelativePath.make("text.txt") }) - yield* service.readTool({ path: RelativePath.make("binary.pdf") }).pipe(Effect.exit) - } - expect(active).toBe(0) - if (before !== undefined) { - const after = yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length)) - expect(after).toBeLessThanOrEqual(before + 2) - } - yield* Effect.promise(() => fs.rename(text, text + ".moved")) - yield* Effect.promise(() => fs.rename(binary, binary + ".moved")) - }).pipe(provide(directory, inertReferences, filesystem)) - }), - ) - it.live("lists direct children with relative paths and resolved URIs", () => withTmp((directory) => Effect.gen(function* () { yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test")) - const service = yield* FileSystem.Service - - const entries = yield* service.list() + const entries = yield* (yield* FileSystem.Service).list() expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([ - { - path: RelativePath.make("src"), - type: "directory", - mime: "application/x-directory", - }, - { - path: RelativePath.make("README.md"), - type: "file", - mime: "text/markdown", - }, + { path: RelativePath.make("src"), type: "directory", mime: "application/x-directory" }, + { path: RelativePath.make("README.md"), type: "file", mime: "text/markdown" }, ]) expect( yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))), @@ -293,293 +84,79 @@ describe("FileSystem", () => { ), ) - it.live("lists stable bounded pages", () => - withTmp((directory) => - Effect.gen(function* () { - yield* Effect.promise(async () => { - await fs.mkdir(path.join(directory, "src")) - await fs.writeFile(path.join(directory, "README.md"), "# Test") - }) - const service = yield* FileSystem.Service - - expect(yield* service.listPage({ limit: 1 })).toMatchObject({ - entries: [{ path: "src", type: "directory" }], - truncated: true, - next: 2, - }) - expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({ - entries: [{ path: "README.md", type: "file" }], - truncated: false, - }) - expect((yield* service.resolveList()).resource).toBe(".") - }).pipe(provide(directory)), - ), - ) - - it.live("materializes only the selected direct children for a page", () => - withTmp((directory) => { - const realPaths: string[] = [] - const filesystem = Layer.effect( - FSUtil.Service, - Effect.gen(function* () { - const service = yield* FSUtil.Service - return FSUtil.Service.of({ - ...service, - realPath: (target) => - Effect.sync(() => realPaths.push(target)).pipe(Effect.andThen(service.realPath(target))), - }) - }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) - return Effect.gen(function* () { - yield* Effect.promise(async () => { - await fs.mkdir(path.join(directory, "src")) - await fs.writeFile(path.join(directory, "alpha.txt"), "alpha") - await fs.writeFile(path.join(directory, "beta.txt"), "beta") - }) - const service = yield* FileSystem.Service - - expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({ - entries: [{ path: "alpha.txt", type: "file" }], - truncated: true, - next: 3, - }) - expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")]) - }).pipe(provide(directory, inertReferences, filesystem)) - }), - ) - - it.live("materializes selected page entries with at most 16 concurrent real path lookups", () => - withTmp((directory) => { - let active = 0 - let maximum = 0 - const filesystem = Layer.effect( - FSUtil.Service, - Effect.gen(function* () { - const service = yield* FSUtil.Service - return FSUtil.Service.of({ - ...service, - realPath: (target) => - target === directory - ? service.realPath(target) - : Effect.acquireUseRelease( - Effect.sync(() => { - active++ - maximum = Math.max(maximum, active) - }), - () => Effect.sleep("10 millis").pipe(Effect.andThen(service.realPath(target))), - () => Effect.sync(() => active--), - ), - }) - }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) - return Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all(Array.from({ length: 32 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), ""))), - ) - const service = yield* FileSystem.Service - - expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32) - expect(maximum).toBe(16) - }).pipe(provide(directory, inertReferences, filesystem)) - }), - ) - - it.live("caps direct list page service calls at 2000 entries", () => - withTmp((directory) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all( - Array.from({ length: 2_001 }, (_, index) => - fs.writeFile(path.join(directory, `${index.toString().padStart(4, "0")}.txt`), ""), - ), - ), - ) - const service = yield* FileSystem.Service - const target = yield* service.resolveList() - - expect((yield* service.listPageResolved(target, { limit: 2_001 })).entries).toHaveLength(2_000) - }).pipe(provide(directory)), - ), - ) - - test("rejects empty list aliases and page limits over 2000", () => { - const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput) - expect(() => decode({ reference: "" })).toThrow() - expect(() => decode({ limit: 2_001 })).toThrow() - }) - - it.live("rejects escaping list paths and omits escaping symlink children", () => + it.live("rejects lexical and symlink escapes", () => withTmp((directory) => Effect.gen(function* () { - if (process.platform === "win32") return - const outside = `${directory}-outside` - yield* Effect.promise(async () => { - await fs.mkdir(outside) - await fs.writeFile(path.join(outside, "secret.txt"), "secret") - await fs.symlink(outside, path.join(directory, "escape")) - }) const service = yield* FileSystem.Service - expect( - Exit.isFailure(yield* service.listPage({ path: RelativePath.make("../outside") }).pipe(Effect.exit)), + Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)), ).toBe(true) - expect((yield* service.listPage()).entries).toEqual([]) - yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true })) - }).pipe(provide(directory)), - ), - ) - - it.live("paginates visible entries after omitting escaping symlink children", () => - withTmp((directory) => - Effect.gen(function* () { if (process.platform === "win32") return - const outside = `${directory}-outside` - yield* Effect.promise(async () => { - await fs.mkdir(outside) - await fs.symlink(outside, path.join(directory, "a-escape")) - await fs.writeFile(path.join(directory, "b-visible.txt"), "visible") - }) - const service = yield* FileSystem.Service - - expect(yield* service.listPage({ limit: 1 })).toMatchObject({ - entries: [{ path: "b-visible.txt", type: "file" }], - truncated: false, - }) - yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true })) + const outside = `${directory}-outside.txt` + yield* Effect.promise(() => fs.writeFile(outside, "outside")) + yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "link.txt"))) + expect(Exit.isFailure(yield* service.read({ path: RelativePath.make("link.txt") }).pipe(Effect.exit))).toBe( + true, + ) + yield* Effect.promise(() => fs.rm(outside, { force: true })) }).pipe(provide(directory)), ), ) - it.live("rejects paths outside the location", () => + it.live("finds and greps files", () => withTmp((directory) => Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "src", "index.ts"), "const needle = true\n")) const service = yield* FileSystem.Service - expect( - Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)), - ).toBe(true) - }).pipe(provide(directory)), - ), - ) - - it.live("reads and lists paths relative to a local project reference", () => - withTmp((directory) => { - const docs = path.join(directory, "docs") - return Effect.gen(function* () { - yield* Effect.promise(async () => { - await fs.mkdir(docs) - await fs.writeFile(path.join(docs, "README.md"), "docs") - }) - const service = yield* FileSystem.Service - - expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({ - type: "text", - content: "docs", - }) - expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }]) - }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } }))) - }), - ) - - it.live("materializes Git references before filesystem access", () => - withTmp((directory) => { - const docs = path.join(directory, "docs") - const ensured: string[] = [] - return Effect.gen(function* () { - yield* Effect.promise(async () => { - await fs.mkdir(docs) - await fs.writeFile(path.join(docs, "README.md"), "docs") - }) - expect( - yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }), - ).toMatchObject({ content: "docs" }) - expect(ensured).toEqual([docs]) + expect((yield* service.find({ query: "index", type: "file" })).map((item) => item.path)).toEqual([ + RelativePath.make(path.join("src", "index.ts")), + ]) + expect(yield* service.grep({ pattern: "needle" })).toMatchObject([ + { path: RelativePath.make(path.join("src", "index.ts")), line: 1, offset: 0 }, + ]) }).pipe( provide( directory, - references( - { - sdk: { - name: "sdk", - kind: "git", - repository: "owner/repo", - reference: Repository.parseRemote("owner/repo"), - path: docs, - }, - }, - (target) => Effect.sync(() => ensured.push(target ?? "")), - ), + Layer.effect( + Search.Service, + Effect.gen(function* () { + const search = yield* Search.Service + return Search.Service.of({ + ...search, + file: () => Effect.succeed([{ path: path.join("src", "index.ts"), type: "file" }]), + }) + }), + ).pipe(Layer.provide(Search.defaultLayer)), ), - ) - }), + ), + ), ) - it.live("rejects unknown, invalid, and escaping project reference paths", () => - withTmp((directory) => { - const docs = path.join(directory, "docs") - return Effect.gen(function* () { - yield* Effect.promise(() => fs.mkdir(docs)) - const service = yield* FileSystem.Service - expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true) - expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true) - expect( - Exit.isFailure( - yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit), - ), - ).toBe(true) + it.live("uses the type supplied by Search file results", () => + withTmp((directory) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.writeFile(path.join(directory, "selected.ts"), "export {}\n")) + expect((yield* (yield* FileSystem.Service).find({ query: "ignored", limit: 1 }))[0]).toMatchObject({ + path: RelativePath.make("selected.ts"), + type: "directory", + mime: "application/x-directory", + }) }).pipe( provide( directory, - references({ - docs: { name: "docs", kind: "local", path: docs }, - invalid: { name: "invalid", kind: "invalid", message: "invalid reference" }, - }), + Layer.effect( + Search.Service, + Effect.gen(function* () { + const search = yield* Search.Service + return Search.Service.of({ + ...search, + file: () => Effect.succeed([{ path: "selected.ts", type: "directory" }]), + }) + }), + ).pipe(Layer.provide(Search.defaultLayer)), ), - ) - }), - ) - - it.live("rejects aliases when project references are disabled", () => - withTmp((directory) => - Effect.gen(function* () { - expect(Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit))).toBe( - true, - ) - }).pipe(provide(directory)), + ), ), ) - - it.live("rejects symlink escapes from project references", () => - withTmp((directory) => { - const docs = path.join(directory, "docs") - const outside = path.join(directory, "outside.txt") - return Effect.gen(function* () { - if (process.platform === "win32") return - yield* Effect.promise(async () => { - await fs.mkdir(docs) - await fs.writeFile(outside, "outside") - await fs.symlink(outside, path.join(docs, "link.txt")) - }) - expect( - Exit.isFailure( - yield* (yield* FileSystem.Service) - .read({ reference: "docs", path: RelativePath.make("link.txt") }) - .pipe(Effect.exit), - ), - ).toBe(true) - }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } }))) - }), - ) }) - -function references( - entries: Record, - ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void, -) { - return ProjectReference.Service.of({ - list: () => Effect.succeed(Object.values(entries)), - get: (name) => Effect.succeed(entries[name]), - resolveMention: () => Effect.succeed(undefined), - ensurePath, - containsManagedPath: () => Effect.succeed(false), - }) -} diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 184901e82f33..3392509de541 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -18,7 +18,7 @@ import { Global } from "../src/global" import { ModelsDev } from "../src/models-dev" import { Npm } from "../src/npm" import { Project } from "../src/project" -import { ProjectReference } from "../src/project-reference" +import { Reference } from "../src/reference" import { LocationSearch } from "../src/location-search" import { ToolRegistry } from "../src/tool/registry" import { ApplicationTools } from "../src/tool/application-tools" @@ -71,7 +71,7 @@ describe("LocationServiceMap", () => { const update = (directory: string) => Effect.gen(function* () { yield* PluginBoot.Service.use((boot) => boot.wait()) - yield* ProjectReference.Service + yield* Reference.Service yield* LocationSearch.Service const catalog = yield* Catalog.Service const transform = yield* catalog.transform() diff --git a/packages/core/test/location-mutation.test.ts b/packages/core/test/location-mutation.test.ts index 91a237f957fc..b9fcba0e3545 100644 --- a/packages/core/test/location-mutation.test.ts +++ b/packages/core/test/location-mutation.test.ts @@ -171,7 +171,7 @@ describe("LocationMutation", () => { ), ) - test("keeps project references outside the mutation input API", () => { + test("ignores unknown mutation input fields", () => { expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"]) expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({ path: "README.md", diff --git a/packages/core/test/location-search.test.ts b/packages/core/test/location-search.test.ts index dd900c2bf04e..2ea6858aeb6d 100644 --- a/packages/core/test/location-search.test.ts +++ b/packages/core/test/location-search.test.ts @@ -5,10 +5,10 @@ import { Cause, Effect, Exit, Layer, Schema } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { FileSystem } from "@opencode-ai/core/filesystem" +import { Search } from "@opencode-ai/core/filesystem/search" import { LocationSearch } from "@opencode-ai/core/location-search" import { AppProcess } from "@opencode-ai/core/process" import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep" -import { ProjectReference } from "@opencode-ai/core/project-reference" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" @@ -16,15 +16,13 @@ import { tmpdir } from "./fixture/tmpdir" import { location } from "./fixture/location" import { it } from "./lib/effect" -const inertReferences = references({}) - -function provide(directory: string, projectReferences = inertReferences, data = Global.Path.data) { +function provide(directory: string, data = Global.Path.data) { const dependencies = Layer.mergeAll( FSUtil.defaultLayer, FileSystemRipgrep.defaultLayer, + Search.defaultLayer, AppProcess.defaultLayer, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - Layer.succeed(ProjectReference.Service, projectReferences), Global.layerWith({ data }), ) const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies)) @@ -56,7 +54,7 @@ describe("LocationSearch", () => { const search = yield* LocationSearch.Service const result = yield* search.grep({ pattern: "FAIL", path: output }) expect(result.items).toMatchObject([{ canonical: output, line: 2, lines: "FAIL here\n" }]) - }).pipe(provide(directory, inertReferences, data)) + }).pipe(provide(directory, data)) }), ) @@ -83,7 +81,7 @@ describe("LocationSearch", () => { ), ) - it.live("searches files under a relative subdirectory and named local reference", () => + it.live("searches files under a relative subdirectory", () => withTmp((directory) => { const docs = path.join(directory, "docs") return Effect.gen(function* () { @@ -98,11 +96,7 @@ describe("LocationSearch", () => { expect( (yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path), ).toEqual([RelativePath.make("src/active.ts")]) - const guide = yield* Effect.promise(() => fs.realpath(path.join(docs, "guide.md"))) - expect((yield* search.files({ pattern: "*.md", reference: "docs" })).items).toMatchObject([ - { path: RelativePath.make("guide.md"), resource: "docs:guide.md", canonical: guide }, - ]) - }).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } }))) + }).pipe(provide(directory)) }), ) @@ -264,13 +258,3 @@ describe("LocationSearch", () => { expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow() }) }) - -function references(entries: Record) { - return ProjectReference.Service.of({ - list: () => Effect.succeed(Object.values(entries)), - get: (name) => Effect.succeed(entries[name]), - resolveMention: () => Effect.succeed(undefined), - ensurePath: () => Effect.void, - containsManagedPath: () => Effect.succeed(false), - }) -} diff --git a/packages/core/test/project-reference.test.ts b/packages/core/test/project-reference.test.ts deleted file mode 100644 index a54ee31cf4d8..000000000000 --- a/packages/core/test/project-reference.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, expect } from "bun:test" -import fs from "fs/promises" -import path from "path" -import { Deferred, Effect, Layer, Schema } from "effect" -import { Config } from "@opencode-ai/core/config" -import { ConfigReference } from "@opencode-ai/core/config/reference" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Global } from "@opencode-ai/core/global" -import { Location } from "@opencode-ai/core/location" -import { ProjectReference } from "@opencode-ai/core/project-reference" -import { Repository } from "@opencode-ai/core/repository" -import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "./fixture/location" -import { tmpdir } from "./fixture/tmpdir" -import { it } from "./lib/effect" - -describe("ProjectReference", () => { - it.live("uses the broad experimental flag unless references are explicitly configured", () => - withEnv( - { OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, - Effect.sync(() => { - expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(true) - }), - ).pipe( - Effect.flatMap(() => - withEnv( - { OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: "false" }, - Effect.sync(() => { - expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(false) - }), - ), - ), - ), - ) - - it.live("normalizes aliases and resolves relative local paths from the project root", () => - withTmp((tmp) => - Effect.gen(function* () { - const project = path.join(tmp.path, "project") - const nested = path.join(project, "packages", "app") - yield* Effect.promise(() => fs.mkdir(nested, { recursive: true })) - - const references = ProjectReference.resolveAll({ - references: ConfigReference.normalize({ - docs: { path: "./docs" }, - home: "~/notes", - sdk: { repository: "owner/repo", branch: "main" }, - shorthand: "owner/other", - invalid: "not-a-repo", - "bad/name": "owner/repo", - }), - directory: project, - home: path.join(tmp.path, "home"), - repos: path.join(tmp.path, "repos"), - }) - - expect(references).toMatchObject([ - { name: "docs", kind: "local", path: path.join(project, "docs") }, - { name: "home", kind: "local", path: path.join(tmp.path, "home", "notes") }, - { name: "sdk", kind: "git", branch: "main" }, - { name: "shorthand", kind: "git" }, - { name: "invalid", kind: "invalid", repository: "not-a-repo" }, - { name: "bad/name", kind: "invalid" }, - ]) - }), - ), - ) - - it.live("marks same-cache references with different branches invalid", () => - Effect.sync(() => { - const references = ProjectReference.resolveAll({ - references: ConfigReference.normalize({ - main: { repository: "owner/repo", branch: "main" }, - dev: { repository: "github.com/owner/repo", branch: "dev" }, - alsoMain: { repository: "https://github.com/owner/repo", branch: "main" }, - }), - directory: "/project", - home: "/home", - repos: "/repos", - }) - - expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"]) - expect(references[1]?.kind === "invalid" ? references[1].message : "").toContain("conflicts with @main") - }), - ) - - it.live("merges config aliases and exposes mention and managed-path operations", () => - withoutReferences( - withTmp((tmp) => { - const calls: RepositoryCache.EnsureInput[] = [] - const project = path.join(tmp.path, "project") - const nested = path.join(project, "packages", "app") - const docs = path.join(project, "docs") - const repos = path.join(tmp.path, "repos") - return Effect.gen(function* () { - yield* Effect.promise(async () => { - await fs.mkdir(nested, { recursive: true }) - await fs.mkdir(docs) - await fs.writeFile(path.join(docs, "README.md"), "docs") - }) - - yield* withReferences( - Effect.gen(function* () { - const references = yield* ProjectReference.Service - const git = path.join(repos, "github.com", "owner", "repo") - - expect(yield* references.list()).toMatchObject([ - { name: "docs", kind: "local", path: docs }, - { name: "sdk", kind: "git", path: git }, - ]) - expect(yield* references.resolveMention("docs/README.md")).toMatchObject({ - name: "docs", - kind: "reference", - target: "README.md", - path: path.join(docs, "README.md"), - }) - expect(yield* references.resolveMention("docs/missing.md")).toMatchObject({ - name: "docs", - kind: "missing", - }) - expect(yield* references.resolveMention("docs/../outside.md")).toMatchObject({ - name: "docs", - kind: "invalid", - }) - expect(yield* references.resolveMention("unknown")).toBeUndefined() - expect(yield* references.resolveMention("sdk")).toMatchObject({ - name: "sdk", - kind: "reference", - path: git, - }) - expect(yield* references.containsManagedPath(path.join(git, "README.md"))).toBe(true) - expect(yield* references.containsManagedPath(path.join(docs, "README.md"))).toBe(false) - yield* references.ensurePath() - expect(calls).toHaveLength(1) - }).pipe( - Effect.provide( - testLayer({ - directory: nested, - project, - repos, - documents: [ - document({ docs: { path: "./old-docs" }, sdk: "owner/old" }), - document({ docs: { path: "./docs" }, sdk: { repository: "owner/repo", branch: "main" } }), - ], - ensure: (input) => Effect.sync(() => result(repos, calls, input)), - }), - ), - ), - ) - }) - }), - ), - ) - - it.live("is inert while the runtime flag is disabled", () => - withoutReferences( - withTmp((tmp) => { - const calls: RepositoryCache.EnsureInput[] = [] - return Effect.gen(function* () { - const references = yield* ProjectReference.Service - expect(yield* references.list()).toEqual([]) - expect(yield* references.get("sdk")).toBeUndefined() - expect(yield* references.resolveMention("sdk")).toBeUndefined() - expect( - yield* references.containsManagedPath(path.join(tmp.path, "repos", "github.com", "owner", "repo")), - ).toBe(false) - yield* references.ensurePath() - expect(calls).toEqual([]) - }).pipe( - Effect.provide( - testLayer({ - directory: tmp.path, - project: tmp.path, - repos: path.join(tmp.path, "repos"), - documents: [document({ sdk: "owner/repo" })], - ensure: (input) => Effect.sync(() => result(path.join(tmp.path, "repos"), calls, input)), - }), - ), - ) - }), - ), - ) - - it.live("starts Git materialization in the background without blocking the location layer", () => - withTmp((tmp) => - Effect.gen(function* () { - const started = yield* Deferred.make() - yield* withReferences( - Effect.gen(function* () { - expect(yield* (yield* ProjectReference.Service).list()).toHaveLength(1) - yield* Deferred.await(started).pipe( - Effect.timeoutOrElse({ - duration: "1 second", - orElse: () => Effect.die(new Error("refresh did not start")), - }), - ) - }).pipe( - Effect.provide( - testLayer({ - directory: tmp.path, - project: tmp.path, - repos: path.join(tmp.path, "repos"), - documents: [document({ sdk: "owner/repo" })], - ensure: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - }), - ), - ), - ) - }), - ), - ) -}) - -function document(references: ConfigReference.Info) { - return new Config.Document({ type: "document", info: Schema.decodeUnknownSync(Config.Info)({ references }) }) -} - -function result( - repos: string, - calls: RepositoryCache.EnsureInput[], - input: RepositoryCache.EnsureInput, -): RepositoryCache.Result { - calls.push(input) - return { - repository: input.reference.label, - host: input.reference.host, - remote: input.reference.remote, - localPath: Repository.cachePath(repos, input.reference), - status: "cached", - branch: input.branch, - } -} - -function testLayer(input: { - directory: string - project: string - repos: string - documents: Config.Document[] - ensure: RepositoryCache.Interface["ensure"] -}) { - return ProjectReference.layer.pipe( - Layer.provide( - Layer.mergeAll( - FSUtil.defaultLayer, - Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }), - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(input.directory) }, - { projectDirectory: AbsolutePath.make(input.project) }, - ), - ), - ), - Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(input.documents) })), - Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })), - ), - ), - ) -} - -function withTmp(body: (tmp: Awaited>) => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - body, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ) -} - -function withReferences(body: Effect.Effect) { - return withEnv({ OPENCODE_EXPERIMENTAL_REFERENCES: "true" }, body) -} - -function withoutReferences(body: Effect.Effect) { - return withEnv({ OPENCODE_EXPERIMENTAL: undefined, OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, body) -} - -function withEnv(env: Record, body: Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]])) - for (const [key, value] of Object.entries(env)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - return previous - }), - () => body, - (previous) => - Effect.sync(() => { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - }), - ) -} diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 645558ffb73a..3608939d7b25 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -59,9 +59,11 @@ describe("Project directories schemas", () => { projectID: ProjectV2.ID.make("project"), }, ) - expect(Schema.decodeUnknownSync(ProjectV2.Directories)([AbsolutePath.make("/tmp/project")])).toEqual([ - AbsolutePath.make("/tmp/project"), - ]) + expect( + Schema.decodeUnknownSync(ProjectV2.Directories)([ + { directory: AbsolutePath.make("/tmp/project"), type: "main" }, + ]), + ).toEqual([{ directory: AbsolutePath.make("/tmp/project"), type: "main" }]) }), ) @@ -90,8 +92,8 @@ describe("Project directories schemas", () => { .pipe(Effect.orDie) expect(yield* project.directories({ projectID })).toEqual([ - AbsolutePath.make("/repo/z"), - AbsolutePath.make("/repo/a"), + { directory: AbsolutePath.make("/repo/z"), type: "root" }, + { directory: AbsolutePath.make("/repo/a"), type: "main" }, ]) }), ) diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts new file mode 100644 index 000000000000..58b28bf06b6b --- /dev/null +++ b/packages/core/test/reference.test.ts @@ -0,0 +1,61 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Layer, Scope } from "effect" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Global } from "@opencode-ai/core/global" +import { Reference } from "@opencode-ai/core/reference" +import { Repository } from "@opencode-ai/core/repository" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" +import { EventV2 } from "@opencode-ai/core/event" +import { it } from "./lib/effect" + +const cache = Layer.mock(RepositoryCache.Service, { + ensure: () => Effect.die("unexpected Git materialization"), +}) + +describe("Reference", () => { + it.effect("registers normalized sources for the owning scope", () => + Effect.gen(function* () { + const references = yield* Reference.Service + const scope = yield* Scope.make() + const update = yield* references.transform().pipe(Effect.provideService(Scope.Scope, scope)) + const path = AbsolutePath.make("/docs") + yield* update((editor) => editor.add("docs", new Reference.LocalSource({ type: "local", path }))) + + expect(yield* references.list()).toEqual([ + new Reference.Info({ name: "docs", path, source: new Reference.LocalSource({ type: "local", path }) }), + ]) + + yield* Scope.close(scope, Exit.void) + expect(yield* references.list()).toEqual([]) + }).pipe( + Effect.provide(Reference.layer), + Effect.provide(cache), + Effect.provide(EventV2.defaultLayer), + Effect.provide(Global.defaultLayer), + ), + ) + + it.effect("derives Git paths without exposing cache operations", () => + Effect.gen(function* () { + const references = yield* Reference.Service + const update = yield* references.transform() + const repository = Repository.parseRemote("owner/repo") + const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" }) + yield* update((editor) => editor.add("sdk", source)) + + expect(yield* references.list()).toEqual([ + new Reference.Info({ + name: "sdk", + path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)), + source, + }), + ]) + }).pipe( + Effect.scoped, + Effect.provide(Reference.layer), + Effect.provide(cache), + Effect.provide(EventV2.defaultLayer), + Effect.provide(Global.defaultLayer), + ), + ) +}) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index c9c63f427f7c..e91c89c00954 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -7,7 +7,7 @@ test("compaction describes tool media without embedding base64", () => { { type: "text", text: "Image read successfully" }, { type: "file", - source: { type: "data", data: base64 }, + uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png", }, diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index d70eebcc4d97..708fd9e7f8ef 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -4,10 +4,9 @@ import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" -import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-ai/core/session/prompt" +import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { SessionV2 } from "@opencode-ai/core/session" -import { ToolOutput } from "@opencode-ai/core/tool-output" import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) @@ -17,7 +16,6 @@ const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat. describe("toLLMMessages", () => { test("maps every top-level V2 Session message type", () => { const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) - const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" }) const messages = toLLMMessages( [ new SessionMessage.AgentSwitched({ @@ -44,7 +42,6 @@ describe("toLLMMessages", () => { text: "Inspect this image", files: [file], agents: [new AgentAttachment({ name: "build" })], - references: [reference], time: { created }, }), new SessionMessage.Synthetic({ @@ -84,7 +81,7 @@ describe("toLLMMessages", () => { { type: "text", text: "Inspect this image" }, { type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" }, ], - metadata: { agents: [{ name: "build" }], references: [reference] }, + metadata: { agents: [{ name: "build" }] }, }), ) expect(messages.slice(2).map((message) => message.content)).toEqual([ @@ -152,13 +149,13 @@ Recent work status: "completed", input: { path: "README.md" }, content: [ - new ToolOutput.TextContent({ type: "text", text: "Hello" }), - new ToolOutput.FileContent({ + { type: "text", text: "Hello" }, + { type: "file", - source: { type: "data", data: "aGVsbG8=" }, + uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png", - }), + }, ], structured: {}, }), @@ -176,7 +173,7 @@ Recent work state: new SessionMessage.ToolStateCompleted({ status: "completed", input: { query: "Effect" }, - content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })], + content: [{ type: "text", text: "Found it" }], structured: {}, }), time: { created, completed: created }, @@ -259,7 +256,7 @@ Recent work type: "content", value: [ { type: "text", text: "Hello" }, - { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" }, + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }, ], }, }, diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index ab6e89109fcf..3d4a858cbbd6 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -57,14 +57,14 @@ const result = LLMEvent.toolResult({ type: "content", value: [ { type: "text", text: "Image read successfully" }, - { type: "media", mediaType: "image/png", data: base64, filename: "pixel.png" }, + { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" }, ], }, output: { structured: { type: "media", mime: "image/png" }, content: [ { type: "text", text: "Image read successfully" }, - { type: "file", source: { type: "data", data: base64 }, mime: "image/png", name: "pixel.png" }, + { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" }, ], }, }) @@ -83,7 +83,7 @@ test("local tool success serializes media base64 once and reconstructs from stru expect(success?.data).toMatchObject({ content: [ { type: "text", text: "Image read successfully" }, - { type: "file", source: { type: "data", data: base64 }, mime: "image/png" }, + { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }, ], }) }) @@ -119,8 +119,8 @@ test("old success event data containing result still decodes", () => { assistantMessageID: SessionMessage.ID.create(), callID: "call-old", structured: { type: "media", mime: "image/png" }, - content: [{ type: "file", source: { type: "data", data: base64 }, mime: "image/png" }], - result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: base64 }] }, + content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }], + result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] }, provider: { executed: false }, }) expect(decoded.result).toMatchObject({ type: "content" }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index bf356f11b85e..73ff5f47ff1c 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1687,7 +1687,7 @@ describe("SessionRunnerLLM", () => { type: "content", value: [ { type: "text", text: "Hello" }, - { type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" }, + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }, ], }, providerExecuted: true, @@ -1740,7 +1740,7 @@ describe("SessionRunnerLLM", () => { structured: {}, content: [ { type: "text", text: "Hello" }, - { type: "file", mime: "image/png", source: { type: "data", data: "aGVsbG8=" }, name: "hello.png" }, + { type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" }, ], }, }, diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 9ed10b69e5ca..09cc159a20ef 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -14,7 +14,6 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql" -import { ToolOutput } from "@opencode-ai/core/tool-output" import { testEffect } from "./lib/effect" const database = Database.layerFromPath(":memory:") @@ -24,7 +23,7 @@ const it = testEffect(Layer.mergeAll(database, events, projector)) const timestamp = DateTime.makeUnsafe(1) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } -const content = (text: string) => [ToolOutput.text({ type: "text", text })] +const content = (text: string) => [{ type: "text" as const, text }] describe("Tool.Progress", () => { it.effect("projects durable progress and keeps final settlements durable", () => diff --git a/packages/core/test/tool-glob.test.ts b/packages/core/test/tool-glob.test.ts index 5d838c57b41e..e323f18d2f29 100644 --- a/packages/core/test/tool-glob.test.ts +++ b/packages/core/test/tool-glob.test.ts @@ -1,6 +1,5 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { FileSystem } from "@opencode-ai/core/filesystem" import { LocationSearch } from "@opencode-ai/core/location-search" import { PermissionV2 } from "@opencode-ai/core/permission" import { RelativePath } from "@opencode-ai/core/schema" @@ -12,7 +11,6 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to const sessionID = SessionV2.ID.make("ses_glob_tool_test") const assertions: PermissionV2.AssertInput[] = [] -const resolutions: FileSystem.ListInput[] = [] const searches: LocationSearch.FilesInput[] = [] let allow = true let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false }) @@ -32,36 +30,6 @@ const permission = Layer.succeed( }), ) -const filesystem = Layer.succeed( - FileSystem.Service, - FileSystem.Service.of({ - read: () => Effect.die("unused"), - resolveReadPath: () => Effect.die("unused"), - readTool: () => Effect.die("unused"), - list: () => Effect.die("unused"), - resolveRoot: (input = {}) => - Effect.sync(() => { - resolutions.push(input) - const relative = input.path ?? RelativePath.make(".") - const resource = input.reference === undefined ? relative : `${input.reference}:${relative}` - return new FileSystem.RootTarget({ - real: `/project/${relative}`, - root: "/project", - resource, - reference: input.reference, - type: "directory", - }) - }), - resolveList: () => Effect.die("unused"), - listResolved: () => Effect.die("unused"), - listPage: () => Effect.die("unused"), - listPageResolved: () => Effect.die("unused"), - find: () => Effect.die("unused"), - grep: () => Effect.die("unused"), - isIgnored: () => false, - }), -) - const search = Layer.succeed( LocationSearch.Service, LocationSearch.Service.of({ @@ -75,17 +43,11 @@ const search = Layer.succeed( ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const glob = GlobTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(filesystem), - Layer.provide(search), -) -const it = testEffect(Layer.mergeAll(registry, permission, filesystem, search, glob)) +const glob = GlobTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(search)) +const it = testEffect(Layer.mergeAll(registry, permission, search, glob)) const reset = () => { assertions.length = 0 - resolutions.length = 0 searches.length = 0 allow = true result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false }) @@ -122,10 +84,9 @@ describe("GlobTool", () => { action: "glob", resources: ["**/*.ts"], save: ["*"], - metadata: { root: "src", reference: undefined, path: "src", limit: 12 }, + metadata: { root: "src", path: "src", limit: 12 }, }, ]) - expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }]) expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }]) }), ) @@ -169,39 +130,6 @@ describe("GlobTool", () => { }), ) - it.effect("searches named references with root and reference metadata", () => - Effect.gen(function* () { - reset() - result = new LocationSearch.FilesResult({ - items: [ - new LocationSearch.File({ - path: RelativePath.make("guide.md"), - canonical: "/project/docs/guide.md", - resource: "docs:guide.md", - mtime: 1, - }), - ], - truncated: false, - partial: false, - }) - - expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({ - type: "text", - value: "docs:guide.md", - }) - expect(assertions).toMatchObject([ - { - sessionID, - action: "glob", - resources: ["*.md"], - save: ["*"], - metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined }, - }, - ]) - expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }]) - }), - ) - it.effect("formats bounded and partial results without discarding structured output", () => Effect.sync(() => { const output = new LocationSearch.FilesResult({ diff --git a/packages/core/test/tool-grep.test.ts b/packages/core/test/tool-grep.test.ts index 98437e88252b..38d9ec48f5cf 100644 --- a/packages/core/test/tool-grep.test.ts +++ b/packages/core/test/tool-grep.test.ts @@ -5,11 +5,11 @@ import { Effect, Exit, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { FileSystem } from "@opencode-ai/core/filesystem" +import { Search } from "@opencode-ai/core/filesystem/search" import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep" import { LocationSearch } from "@opencode-ai/core/location-search" import { PermissionV2 } from "@opencode-ai/core/permission" import { AppProcess } from "@opencode-ai/core/process" -import { ProjectReference } from "@opencode-ai/core/project-reference" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -27,32 +27,6 @@ let allow = true let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false }) let searchFailure: Ripgrep.InvalidPatternError | undefined -const filesystem = Layer.succeed( - FileSystem.Service, - FileSystem.Service.of({ - read: () => Effect.die("unused"), - resolveReadPath: () => Effect.die("unused"), - readTool: () => Effect.die("unused"), - list: () => Effect.die("unused"), - resolveRoot: (input = {}) => - Effect.succeed( - new FileSystem.RootTarget({ - real: `/project/${input.path ?? "."}`, - root: "/project", - resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`, - reference: input.reference, - type: "directory", - }), - ), - resolveList: () => Effect.die("unused"), - listResolved: () => Effect.die("unused"), - listPage: () => Effect.die("unused"), - listPageResolved: () => Effect.die("unused"), - find: () => Effect.die("unused"), - grep: () => Effect.die("unused"), - isIgnored: () => false, - }), -) const search = Layer.succeed( LocationSearch.Service, LocationSearch.Service.of({ @@ -80,13 +54,8 @@ const permission = Layer.succeed( }), ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const grep = GrepTool.layer.pipe( - Layer.provide(registry), - Layer.provide(filesystem), - Layer.provide(search), - Layer.provide(permission), -) -const it = testEffect(Layer.mergeAll(registry, filesystem, search, permission, grep)) +const grep = GrepTool.layer.pipe(Layer.provide(registry), Layer.provide(search), Layer.provide(permission)) +const it = testEffect(Layer.mergeAll(registry, search, permission, grep)) const sessionID = SessionV2.ID.make("ses_grep_tool_test") const execute = (input: Record) => @@ -115,23 +84,13 @@ const reset = () => { result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false }) } -function references(entries: Record) { - return ProjectReference.Service.of({ - list: () => Effect.succeed(Object.values(entries)), - get: (name) => Effect.succeed(entries[name]), - resolveMention: () => Effect.succeed(undefined), - ensurePath: () => Effect.void, - containsManagedPath: () => Effect.succeed(false), - }) -} - -function provideLive(directory: string, projectReferences = references({})) { +function provideLive(directory: string) { const dependencies = Layer.mergeAll( FSUtil.defaultLayer, FileSystemRipgrep.defaultLayer, + Search.defaultLayer, AppProcess.defaultLayer, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), - Layer.succeed(ProjectReference.Service, projectReferences), ) const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies)) const search = LocationSearch.layer.pipe( @@ -170,29 +129,13 @@ describe("GrepTool", () => { action: "grep", resources: ["needle"], save: ["*"], - metadata: { root: "src", reference: undefined, path: RelativePath.make("src"), include: "*.ts", limit: 2 }, + metadata: { root: "src", path: RelativePath.make("src"), include: "*.ts", limit: 2 }, }, ]) expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }]) }), ) - it.effect("delegates named reference grep and exposes the canonical selected root in metadata", () => - Effect.gen(function* () { - reset() - - yield* execute({ pattern: "guide", path: "docs", reference: "manual", include: "*.md" }) - - expect(assertions[0]).toMatchObject({ - resources: ["guide"], - metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" }, - }) - expect(searches).toEqual([ - { pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" }, - ]) - }), - ) - it.effect("does not search when permission is denied", () => Effect.gen(function* () { reset() @@ -248,7 +191,7 @@ describe("GrepTool", () => { }), ) - runtimeIt.live("greps active Location and named-reference files with include globs", () => + runtimeIt.live("greps active Location files with include globs", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -259,23 +202,15 @@ describe("GrepTool", () => { reset() yield* Effect.promise(async () => { await fs.mkdir(path.join(tmp.path, "src")) - await fs.mkdir(docs) await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n") await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n") - await fs.writeFile(path.join(docs, "guide.md"), "needle docs\n") }) expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({ type: "text", value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n", }) - expect(yield* execute({ pattern: "needle", reference: "docs", include: "*.md" })).toEqual({ - type: "text", - value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n", - }) - }).pipe( - Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))), - ) + }).pipe(Effect.provide(provideLive(tmp.path))) }), ), ) diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index 96727ade972a..f0504e9759d7 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -90,7 +90,7 @@ describe("ToolOutputStore", () => { toolCallID: "call-file", output: { structured: { caption: "pixel" }, - content: [{ type: "file", source: { type: "data", data }, mime: "image/png", name: "pixel.png" }], + content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }], }, }) expect(result.outputPaths).toEqual([]) @@ -98,7 +98,7 @@ describe("ToolOutputStore", () => { expect(result.output.content).toHaveLength(1) expect(result.output.content[0]).toEqual({ type: "file", - source: { type: "data", data }, + uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png", }) @@ -112,7 +112,7 @@ describe("ToolOutputStore", () => { const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1) const media = { type: "file" as const, - source: { type: "data" as const, data: "aGVsbG8=" }, + uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "pixel.png", } diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index fa317a00e532..605a1e17da56 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -3,60 +3,51 @@ import { Effect, Exit, Layer } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigAttachments } from "@opencode-ai/core/config/attachments" import { FileSystem } from "@opencode-ai/core/filesystem" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" import { Image } from "@opencode-ai/core/image" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Global } from "@opencode-ai/core/global" +import { location } from "./fixture/location" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ReadTool } from "@opencode-ai/core/tool/read" +import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const assertions: PermissionV2.AssertInput[] = [] const readCalls: { - input: FileSystem.ReadInput & FileSystem.TextPageInput - page: FileSystem.TextPageInput + input: AbsolutePath + page: ReadToolFileSystem.PageInput }[] = [] -const listCalls: FileSystem.ListPageInput[] = [] +const listCalls: ReadToolFileSystem.PageInput[] = [] let resolvedType: "file" | "directory" = "file" let resolveFailure: unknown -let readResult: FileSystem.Content | FileSystem.TextPage = new FileSystem.TextContent({ - type: "text", +let readResult: FileSystem.Content | ReadToolFileSystem.TextPage = { + uri: "file:///README.md", + name: "README.md", content: "hello", + encoding: "utf8", mime: "text/plain", -}) +} let readFailure: unknown let configEntries: Config.Entry[] = [] -const filesystem = Layer.succeed( - FileSystem.Service, - FileSystem.Service.of({ - read: () => Effect.die("unused"), - resolveReadPath: (input) => - resolveFailure === undefined - ? Effect.succeed( - new FileSystem.ReadPath({ - type: resolvedType, - resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`, - }), - ) - : Effect.die(resolveFailure), - readTool: (input, page = {}) => { +const reader = Layer.succeed( + ReadToolFileSystem.Service, + ReadToolFileSystem.Service.of({ + inspect: () => (resolveFailure === undefined ? Effect.succeed(resolvedType) : Effect.die(resolveFailure)), + read: (input, _resource, page = {}) => { readCalls.push({ input, page }) if (readFailure !== undefined) return Effect.die(readFailure) return Effect.succeed(readResult) }, - resolveRoot: () => Effect.die("unused"), - list: () => Effect.die("unused"), - resolveList: () => Effect.die("unused"), - listResolved: () => Effect.die("unused"), - listPage: (input = {}) => + list: (_path, input = {}) => Effect.sync(() => { listCalls.push(input) - return new FileSystem.ListPage({ entries: [], truncated: false }) + return new ReadToolFileSystem.ListPage({ entries: [], truncated: false }) }), - listPageResolved: () => Effect.die("unused"), - find: () => Effect.die("unused"), - grep: () => Effect.die("unused"), - isIgnored: () => false, }), ) let allow = true @@ -77,27 +68,38 @@ const permission = Layer.succeed( const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) })) const image = Image.layer.pipe(Layer.provide(config)) +const testFileSystem = Layer.effect( + FSUtil.Service, + FSUtil.Service.use((fs) => Effect.succeed(FSUtil.Service.of({ ...fs, realPath: (path) => Effect.succeed(path) }))), +).pipe(Layer.provide(FSUtil.defaultLayer)) +const infrastructure = Layer.mergeAll( + testFileSystem, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))), + Global.layerWith({ data: Global.Path.data }), +) const unavailableImage = Layer.succeed( Image.Service, Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), ) const read = ReadTool.layer.pipe( Layer.provide(registry), - Layer.provide(filesystem), + Layer.provide(reader), Layer.provide(permission), Layer.provide(config), Layer.provide(image), + Layer.provide(infrastructure), ) -const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, image, read)) +const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read)) const unavailableRead = ReadTool.layer.pipe( Layer.provide(registry), - Layer.provide(filesystem), + Layer.provide(reader), Layer.provide(permission), Layer.provide(config), Layer.provide(unavailableImage), + Layer.provide(infrastructure), ) const itWithoutResizer = testEffect( - Layer.mergeAll(registry, filesystem, permission, config, unavailableImage, unavailableRead), + Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead), ) const sessionID = SessionV2.ID.make("ses_read_tool_test") @@ -109,7 +111,13 @@ describe("ReadTool", () => { allow = true resolvedType = "file" resolveFailure = undefined - readResult = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" }) + readResult = { + uri: "file:///README.md", + name: "README.md", + content: "hello", + encoding: "utf8", + mime: "text/plain", + } readFailure = undefined configEntries = [] }) @@ -126,21 +134,31 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, }), - ).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } }) + ).toEqual({ + type: "json", + value: { + uri: "file:///README.md", + name: "README.md", + content: "hello", + encoding: "utf8", + mime: "text/plain", + }, + }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) - expect(readCalls).toEqual([{ input: { path: "README.md" }, page: {} }]) + expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/README.md`), page: {} }]) }), ) it.effect("returns a small PNG as native media instead of durable base64 text", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" - readResult = new FileSystem.BinaryContent({ - type: "binary", + readResult = { + uri: "file:///pixel.png", + name: "pixel.png", content: png, encoding: "base64", mime: "image/png", - }) + } const registry = yield* ToolRegistry.Service expect( @@ -153,20 +171,25 @@ describe("ReadTool", () => { type: "content", value: [ { type: "text", text: "Image read successfully" }, - { type: "media", mediaType: "image/png", data: png, filename: "pixel.png" }, + { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" }, ], }) - expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }]) + expect(readCalls).toEqual([{ input: AbsolutePath.make(`${process.cwd()}/pixel.png`), page: {} }]) const settled = yield* settleTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } }, }) - expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" }) + expect(settled.output?.structured).toMatchObject({ + uri: "file:///pixel.png", + name: "pixel.png", + mime: "image/png", + encoding: "base64", + }) expect(settled.output?.content).toMatchObject([ { type: "text", text: "Image read successfully" }, - { type: "file", mime: "image/png", source: { type: "data", data: png } }, + { type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` }, ]) }), ) @@ -179,12 +202,13 @@ describe("ReadTool", () => { const png = Buffer.from(source.get_bytes()).toString("base64") source.free() expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024) - readResult = new FileSystem.BinaryContent({ - type: "binary", + readResult = { + uri: "file:///large.png", + name: "large.png", content: png, encoding: "base64", mime: "image/png", - }) + } const registry = yield* ToolRegistry.Service const settled = yield* settleTool(registry, { @@ -194,12 +218,17 @@ describe("ReadTool", () => { }) expect(settled.outputPaths).toBeUndefined() - expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" }) + expect(settled.output?.structured).toMatchObject({ + uri: "file:///large.png", + name: "large.png", + mime: "image/png", + encoding: "base64", + }) expect(settled.result).toEqual({ type: "content", value: [ { type: "text", text: "Image read successfully" }, - { type: "media", mediaType: "image/png", data: png, filename: "large.png" }, + { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" }, ], }) }), @@ -208,12 +237,13 @@ describe("ReadTool", () => { itWithoutResizer.effect("returns the original image when the resizer is unavailable", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" - readResult = new FileSystem.BinaryContent({ - type: "binary", + readResult = { + uri: "file:///pixel.png", + name: "pixel.png", content: png, encoding: "base64", mime: "image/png", - }) + } const registry = yield* ToolRegistry.Service expect( @@ -224,19 +254,20 @@ describe("ReadTool", () => { }), ).toMatchObject({ type: "content", - value: [{ type: "text" }, { type: "media", mediaType: "image/png", data: png }], + value: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }], }) }), ) it.effect("rejects invalid image data returned by the filesystem", () => Effect.gen(function* () { - readResult = new FileSystem.BinaryContent({ - type: "binary", + readResult = { + uri: "file:///truncated.png", + name: "truncated.png", content: "iVBORw0KGgo=", encoding: "base64", mime: "image/png", - }) + } const registry = yield* ToolRegistry.Service expect( @@ -255,12 +286,13 @@ describe("ReadTool", () => { const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1) const base64 = Buffer.from(source.get_bytes()).toString("base64") source.free() - readResult = new FileSystem.BinaryContent({ - type: "binary", + readResult = { + uri: "file:///wide.png", + name: "wide.png", content: base64, encoding: "base64", mime: "image/png", - }) + } configEntries = [ new Config.Document({ type: "document", @@ -289,12 +321,13 @@ describe("ReadTool", () => { const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1) const base64 = Buffer.from(source.get_bytes()).toString("base64") source.free() - readResult = new FileSystem.BinaryContent({ - type: "binary", + readResult = { + uri: "file:///wide.png", + name: "wide.png", content: base64, encoding: "base64", mime: "image/png", - }) + } configEntries = [ new Config.Document({ type: "document", @@ -313,9 +346,9 @@ describe("ReadTool", () => { expect(result.type).toBe("content") if (result.type !== "content") return const media = result.value[1] - expect(media?.type).toBe("media") - if (media?.type !== "media") return - const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.data, "base64")) + expect(media?.type).toBe("file") + if (media?.type !== "file") return + const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.uri.split(",")[1] ?? "", "base64")) expect(resized.get_width()).toBeLessThanOrEqual(4) expect(resized.get_height()).toBeLessThanOrEqual(2_000) resized.free() @@ -325,12 +358,13 @@ describe("ReadTool", () => { it.effect("enforces max base64 bytes after resize attempts", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" - readResult = new FileSystem.BinaryContent({ - type: "binary", + readResult = { + uri: "file:///pixel.png", + name: "pixel.png", content: png, encoding: "base64", mime: "image/png", - }) + } configEntries = [ new Config.Document({ type: "document", @@ -356,12 +390,13 @@ describe("ReadTool", () => { it.effect("returns supported image contents despite a misleading binary extension", () => Effect.gen(function* () { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" - readResult = new FileSystem.BinaryContent({ - type: "binary", + readResult = { + uri: "file:///pixel.bin", + name: "pixel.bin", content: png, encoding: "base64", mime: "image/png", - }) + } const registry = yield* ToolRegistry.Service expect( @@ -372,14 +407,14 @@ describe("ReadTool", () => { }), ).toMatchObject({ type: "content", - value: [{ type: "text" }, { type: "media", mediaType: "image/png", filename: "pixel.bin" }], + value: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }], }) }), ) it.effect("preserves unexpected filesystem defects", () => Effect.gen(function* () { - readFailure = new FileSystem.BinaryFileError("archive.dat") + readFailure = new ReadToolFileSystem.BinaryFileError("archive.dat") const registry = yield* ToolRegistry.Service expect( @@ -397,7 +432,7 @@ describe("ReadTool", () => { ), ).toBe(true) expect(readCalls).toEqual([ - { input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }, + { input: AbsolutePath.make(`${process.cwd()}/archive.dat`), page: { offset: 2, limit: 1 } }, ]) }), ) @@ -436,7 +471,7 @@ describe("ReadTool", () => { }), ).toEqual({ type: "json", value: { entries: [], truncated: false } }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }]) - expect(listCalls).toEqual([{ path: "src", offset: 2, limit: 10 }]) + expect(listCalls).toEqual([{ offset: 2, limit: 10 }]) }), ) @@ -457,20 +492,6 @@ describe("ReadTool", () => { }), ) - it.effect("authorizes project references with their canonical identity", () => - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } }, - }) - - expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }]) - }), - ) - it.effect("preserves unexpected resolution defects", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service @@ -492,7 +513,7 @@ describe("ReadTool", () => { it.effect("forwards pagination and returns bounded text pages with continuation", () => Effect.gen(function* () { - readResult = new FileSystem.TextPage({ + readResult = new ReadToolFileSystem.TextPage({ type: "text-page", content: "hello", mime: "text/plain", @@ -517,18 +538,21 @@ describe("ReadTool", () => { type: "json", value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, }) - expect(readCalls).toEqual([{ input: { path: "large.txt", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }]) + expect(readCalls).toEqual([ + { input: AbsolutePath.make(`${process.cwd()}/large.txt`), page: { offset: 2, limit: 1 } }, + ]) }), ) it.effect("rejects unsupported binary discovered by a direct read", () => Effect.gen(function* () { - readResult = new FileSystem.BinaryContent({ - type: "binary", + readResult = { + uri: "file:///late-binary", + name: "late-binary", content: "AAECAw==", encoding: "base64", mime: "application/octet-stream", - }) + } const registry = yield* ToolRegistry.Service expect( diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 76f3e4d8bf97..8c08454c4f49 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -1,5 +1,6 @@ import fs from "fs/promises" import path from "path" +import { pathToFileURL } from "url" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -100,6 +101,9 @@ describe("SkillTool", () => { type: "text", value: SkillTool.toModelOutput(info, [reference]), }) + expect(SkillTool.toModelOutput(info, [reference])).toContain( + `Base directory for this skill: ${pathToFileURL(directory).href}`, + ) expect( yield* settleTool(registry, { sessionID, diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index a0a849ddbe95..0ec17c1e6317 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -65,19 +65,25 @@ function spawnWorker(msg: Msg) { }) } -function stopWorker(proc: ReturnType) { - if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() +async function stopWorker(proc: ReturnType) { + if (proc.exitCode !== null || proc.signalCode !== null) return + + const closed = new Promise((resolve) => proc.once("close", () => resolve())) + if (process.platform !== "win32" || !proc.pid) { proc.kill() - return Promise.resolve() + await closed + return } - return new Promise((resolve) => { + + await new Promise((resolve) => { const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) killProc.on("close", () => { proc.kill() resolve() }) }) + await closed } async function waitForFile(file: string, timeout = 3_000) { @@ -363,7 +369,6 @@ describe("util.effect-flock", () => { try { await waitForFile(ready, 5_000) await stopWorker(proc) - await new Promise((resolve) => proc.on("close", resolve)) // Backdate lock files so they're past STALE_MS (60s) const lockDir = lock(dir, "eflock:crash") diff --git a/packages/core/test/util/flock.test.ts b/packages/core/test/util/flock.test.ts index e1b647b64801..53bfc1874ea8 100644 --- a/packages/core/test/util/flock.test.ts +++ b/packages/core/test/util/flock.test.ts @@ -88,21 +88,25 @@ function spawnWorker(msg: Msg) { }) } -function stopWorker(proc: ReturnType) { - if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() +async function stopWorker(proc: ReturnType) { + if (proc.exitCode !== null || proc.signalCode !== null) return + + const closed = new Promise((resolve) => proc.once("close", () => resolve())) if (process.platform !== "win32" || !proc.pid) { proc.kill() - return Promise.resolve() + await closed + return } - return new Promise((resolve) => { + await new Promise((resolve) => { const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) killProc.on("close", () => { proc.kill() resolve() }) }) + await closed } async function readJson(p: string): Promise { @@ -175,7 +179,6 @@ describe("util.flock", () => { expect(seen.every((x) => x === key)).toBe(true) } finally { await stopWorker(proc).catch(() => undefined) - await new Promise((resolve) => proc.on("close", resolve)) } }, 15_000) @@ -195,7 +198,6 @@ describe("util.flock", () => { await wait(ready, 5_000) await stopWorker(proc) - await new Promise((resolve) => proc.on("close", resolve)) let hit = false await Flock.withLock( diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 986008c4f4f1..7fd03ae621d7 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -74,6 +74,7 @@ const getBase = (): Configuration => ({ linux: { icon: `resources/icons`, category: "Development", + executableName: "opencode-desktop", target: ["AppImage", "deb", "rpm"], }, }) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index bd5384423a2f..dcf9b1d486ac 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -28,8 +28,8 @@ "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", - "electron-store": "^10", - "electron-updater": "^6", + "electron-store": "11.0.2", + "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", "marked": "^15" }, @@ -48,8 +48,8 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "@valibot/to-json-schema": "1.6.0", - "electron": "41.2.1", - "electron-builder": "^26", + "electron": "42.3.3", + "electron-builder": "26.15.0", "electron-vite": "^5", "solid-js": "catalog:", "sury": "11.0.0-alpha.4", diff --git a/packages/desktop/src/main/constants.ts b/packages/desktop/src/main/constants.ts index 7991570aefda..258deb7c6de7 100644 --- a/packages/desktop/src/main/constants.ts +++ b/packages/desktop/src/main/constants.ts @@ -4,8 +4,4 @@ type Channel = "dev" | "beta" | "prod" const raw = import.meta.env.OPENCODE_CHANNEL export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev" -export const SETTINGS_STORE = "opencode.settings" -export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" -export const WSL_SERVERS_KEY = "wslServers" -export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts index c930820628de..d69e6feec257 100644 --- a/packages/desktop/src/main/env.d.ts +++ b/packages/desktop/src/main/env.d.ts @@ -15,8 +15,5 @@ declare module "virtual:opencode-server" { export const get: typeof import("../../../opencode/dist/types/src/node").Config.get export type Info = import("../../../opencode/dist/types/src/node").Config.Info } - export namespace Log { - export const init: typeof import("../../../opencode/dist/types/src/node").Log.init - } export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap } diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c269d089be48..383e4d994232 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -145,8 +145,10 @@ const main = Effect.gen(function* () { }) }, { - log: (message, meta) => logger.log(message, meta), - error: (message, meta) => logger.error(message, meta), + logger: { + log: (message, meta) => logger.log(message, meta), + error: (message, meta) => logger.error(message, meta), + }, }, ) const stopSidecars = async () => { @@ -327,7 +329,9 @@ const main = Effect.gen(function* () { password, }) - void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) + if (process.platform === "win32") { + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) + } yield* Effect.promise(() => health.wait).pipe( Effect.timeout("30 seconds"), diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 329cf9d6d433..b213dbc82a23 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -2,9 +2,10 @@ import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" import { app, utilityProcess } from "electron" import type { Details } from "electron" -import { DEFAULT_SERVER_URL_KEY } from "./constants" +import { getLogger } from "./logging" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" +import { DEFAULT_SERVER_URL_KEY } from "./store-keys" export type HealthCheck = { wait: Promise } @@ -43,7 +44,7 @@ export function setDefaultServerUrl(url: string | null) { export function preferAppEnv(userDataPath: string) { const shell = process.platform === "win32" ? null : getUserShell() Object.assign(process.env, { - ...(shell ? loadShellEnv(shell) : null), + ...(shell ? loadShellEnv(shell, getLogger()) : null), OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_CLIENT: "desktop", diff --git a/packages/desktop/src/main/shell-env.ts b/packages/desktop/src/main/shell-env.ts index deb43033aefc..082ed5e930db 100644 --- a/packages/desktop/src/main/shell-env.ts +++ b/packages/desktop/src/main/shell-env.ts @@ -1,11 +1,13 @@ import { spawnSync } from "node:child_process" import { userInfo } from "node:os" import { basename } from "node:path" -import { getLogger } from "./logging" const TIMEOUT = 5_000 type Probe = { type: "Loaded"; value: Record } | { type: "Timeout" } | { type: "Unavailable" } +type ShellEnvLogger = { + log: (message: string) => void +} export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) { const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined @@ -65,8 +67,7 @@ export function isNushell(shell: string) { return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe") } -export function loadShellEnv(shell: string) { - const logger = getLogger() +export function loadShellEnv(shell: string, logger: ShellEnvLogger) { if (isNushell(shell)) { logger.log(`[server] Skipping shell env probe for nushell: ${shell}`) return null diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts index 38ee13063499..246871fb2b4c 100644 --- a/packages/desktop/src/main/sidecar.ts +++ b/packages/desktop/src/main/sidecar.ts @@ -54,8 +54,7 @@ async function start(command: StartCommand) { ensureLoopbackNoProxy() useSystemCertificates() useEnvProxy() - const { Log, Server } = await import("virtual:opencode-server") - await Log.init({ level: "WARN" }) + const { Server } = await import("virtual:opencode-server") listener = await Server.listen({ port: command.port, diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts new file mode 100644 index 000000000000..f05018a26920 --- /dev/null +++ b/packages/desktop/src/main/store-keys.ts @@ -0,0 +1,4 @@ +export const SETTINGS_STORE = "opencode.settings" +export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" +export const WSL_SERVERS_KEY = "wslServers" +export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" diff --git a/packages/desktop/src/main/store.ts b/packages/desktop/src/main/store.ts index a591f878decf..b99497f9b1c5 100644 --- a/packages/desktop/src/main/store.ts +++ b/packages/desktop/src/main/store.ts @@ -1,7 +1,7 @@ import Store from "electron-store" -import { app } from "electron" +import electron from "electron" -import { SETTINGS_STORE } from "./constants" +import { SETTINGS_STORE } from "./store-keys" const cache = new Map() @@ -14,7 +14,7 @@ export function getStore(name = SETTINGS_STORE) { if (cached) return cached const next = new Store({ name, - cwd: app.getPath("userData"), + cwd: electron.app.getPath("userData"), fileExtension: "", accessPropertiesByDotNotation: false, }) diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 1bdfb1042459..e0179f54c656 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -6,9 +6,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f import { dirname, isAbsolute, join, relative, resolve } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" import type { TitlebarTheme } from "../preload/types" -import { PINCH_ZOOM_ENABLED_KEY } from "./constants" import { exportDebugLogs, write as writeLog } from "./logging" import { getStore } from "./store" +import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys" import { createUnresponsiveSampler } from "./unresponsive" const root = dirname(fileURLToPath(import.meta.url)) diff --git a/packages/desktop/src/main/wsl/ipc.ts b/packages/desktop/src/main/wsl/ipc.ts index c31fd680c036..ed98004beeb3 100644 --- a/packages/desktop/src/main/wsl/ipc.ts +++ b/packages/desktop/src/main/wsl/ipc.ts @@ -2,8 +2,14 @@ import { app, ipcMain } from "electron" import type { IpcMainInvokeEvent } from "electron" import type { WslServersController } from "./servers" import { requireWslIpcString } from "./policy" +import type { WslServersState } from "../../preload/types" export function registerWslIpcHandlers(controller: WslServersController) { + if (process.platform !== "win32") { + registerUnavailableWslIpcHandlers() + return + } + const subscriptions = new Map void>() const unsubscribe = (id: number) => { const off = subscriptions.get(id) @@ -62,3 +68,40 @@ export function registerWslIpcHandlers(controller: WslServersController) { controller.startServer(requireWslIpcString("server id", id)), ) } + +function registerUnavailableWslIpcHandlers() { + const unavailable = () => { + throw new Error("WSL is only available on Windows") + } + const state = (): WslServersState => ({ + runtime: { + available: false, + version: null, + error: "WSL is only available on Windows", + }, + installed: [], + online: [], + distroProbes: {}, + opencodeChecks: {}, + pendingRestart: false, + servers: [], + job: null, + }) + + ipcMain.handle("wsl-servers-subscribe", (event) => { + event.sender.send("wsl-servers-event", { type: "state", state: state() }) + }) + ipcMain.handle("wsl-servers-unsubscribe", () => undefined) + ipcMain.handle("wsl-servers-get-state", () => state()) + ipcMain.handle("wsl-servers-probe-runtime", unavailable) + ipcMain.handle("wsl-servers-refresh-distros", unavailable) + ipcMain.handle("wsl-servers-install-wsl", unavailable) + ipcMain.handle("wsl-servers-install-distro", unavailable) + ipcMain.handle("wsl-servers-probe-distro", unavailable) + ipcMain.handle("wsl-servers-probe-opencode", unavailable) + ipcMain.handle("wsl-servers-install-opencode", unavailable) + ipcMain.handle("wsl-servers-open-terminal", unavailable) + ipcMain.handle("wsl-servers-add", unavailable) + ipcMain.handle("wsl-servers-remove", unavailable) + ipcMain.handle("wsl-servers-start", unavailable) +} diff --git a/packages/desktop/src/main/wsl/servers.test.ts b/packages/desktop/src/main/wsl/servers.test.ts index 98f88133ca24..fcca712a0471 100644 --- a/packages/desktop/src/main/wsl/servers.test.ts +++ b/packages/desktop/src/main/wsl/servers.test.ts @@ -6,6 +6,10 @@ import { pollWslHealth, wslServerIdsToStartOnInitialize, } from "./startup" +import { createWslServersController, type WslServerConfig } from "./servers" + +let persistedServers: WslServerConfig[] = [] +let releaseOpencodeResolve: (() => void) | undefined test("starts every configured WSL server on initialization", () => { expect( @@ -91,3 +95,73 @@ test("derives a required Windows restart from the post-install runtime probe", ( expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true) expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false) }) + +test("ignores stale background OpenCode checks after removing a WSL server", async () => { + persistedServers = [] + releaseOpencodeResolve = undefined + const controller = createWslServersController( + "1.16.2", + async () => ({ + listener: { + stop: () => undefined, + onExit: () => undefined, + }, + url: "http://127.0.0.1:4096", + username: "opencode", + password: "secret", + }), + testControllerOptions(), + ) + + await controller.addServer("Debian") + await waitFor(() => !!releaseOpencodeResolve) + await controller.removeServer("wsl:Debian") + releaseOpencodeResolve?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(controller.getState().servers).toEqual([]) + expect(controller.getState().opencodeChecks).toEqual({}) +}) + +test("ignores stale startup OpenCode checks after removing a WSL server", async () => { + persistedServers = [{ id: "wsl:Debian", distro: "Debian" }] + releaseOpencodeResolve = undefined + const controller = createWslServersController( + "1.16.2", + async () => new Promise(() => undefined), + testControllerOptions(), + ) + + await controller.initialize() + await waitFor(() => !!releaseOpencodeResolve) + await controller.removeServer("wsl:Debian") + releaseOpencodeResolve?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(controller.getState().servers).toEqual([]) + expect(controller.getState().opencodeChecks).toEqual({}) +}) + +async function waitFor(check: () => boolean) { + for (let attempt = 0; attempt < 20; attempt++) { + if (check()) return + await new Promise((resolve) => setTimeout(resolve, 0)) + } + throw new Error("Timed out waiting for condition") +} + +function testControllerOptions() { + return { + readServers: () => persistedServers, + writeServers: (servers: WslServerConfig[]) => { + persistedServers = servers + }, + readCommandVersion: async () => "1.16.2", + resolveOpencode: async () => { + await new Promise((resolve) => { + releaseOpencodeResolve = resolve + }) + return "/home/me/.opencode/bin/opencode" + }, + } +} diff --git a/packages/desktop/src/main/wsl/servers.ts b/packages/desktop/src/main/wsl/servers.ts index dfd0112429d1..b9cd9201faf7 100644 --- a/packages/desktop/src/main/wsl/servers.ts +++ b/packages/desktop/src/main/wsl/servers.ts @@ -11,7 +11,7 @@ import type { WslServersEvent, WslServersState, } from "../../preload/types" -import { WSL_SERVERS_KEY } from "../constants" +import { WSL_SERVERS_KEY } from "../store-keys" import { getStore } from "../store" import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup" import { clearWslDistroState, wslServerIdToRestart } from "./policy" @@ -43,18 +43,33 @@ type ControllerLogger = { error: (message: string, meta?: unknown) => void } +type WslServersControllerOptions = { + logger?: ControllerLogger + readServers?: () => WslServerConfig[] + writeServers?: (servers: WslServerConfig[]) => void + resolveOpencode?: typeof resolveWslOpencode + readCommandVersion?: typeof readWslCommandVersion +} + export type WslServersController = ReturnType export function wslServerIdForDistro(distro: string) { return `wsl:${distro}` } -export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) { +export function createWslServersController( + appVersion: string, + spawnSidecar: SpawnSidecar, + options?: WslServersControllerOptions, +) { let state: WslServersState = initialState() const listeners = new Set<(event: WslServersEvent) => void>() const sidecars = new Map() const startAttempts = new Map() let jobAbort: AbortController | undefined + const logger = options?.logger + const readServers = options?.readServers ?? readPersistedServers + const writeServers = options?.writeServers ?? writePersistedServers const emit = () => { for (const listener of listeners) listener({ type: "state", state }) @@ -66,7 +81,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa } const persistServers = (servers: WslServerConfig[]) => { - getStore().set(WSL_SERVERS_KEY, { servers }) + writeServers(servers) } const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => { @@ -89,7 +104,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa } const refreshFromStore = () => { - const persisted = readPersistedServers() + const persisted = readServers() const items: WslServerItem[] = persisted.map((config) => { const existing = state.servers.find((item) => item.config.id === config.id) return { @@ -113,10 +128,52 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa }) } + const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => { + const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts) + const version = resolved + ? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts) + : null + return opencodeCheck(distro, resolved, version, appVersion) + } + const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => { - const resolved = await resolveWslOpencode(distro, opts) - const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null - setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion)) + setOpencodeCheck(distro, await checkOpencode(distro, opts)) + } + + const hasServer = (id: string, distro: string) => { + return state.servers.some((item) => item.config.id === id && item.config.distro === distro) + } + + const refreshOpencodeCheckBackground = (id: string, distro: string) => { + void checkOpencode(distro) + .then((check) => { + if (!hasServer(id, distro)) return + setOpencodeCheck(distro, check) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + logger?.error("wsl opencode check failed", { id, distro, message }) + }) + } + + const refreshOpencodeChecks = async () => { + await Promise.all( + state.servers.map((item) => + checkOpencode(item.config.distro) + .then((check) => { + if (!hasServer(item.config.id, item.config.distro)) return + setOpencodeCheck(item.config.distro, check) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + logger?.error("wsl opencode check failed", { + id: item.config.id, + distro: item.config.distro, + message, + }) + }), + ), + ) } const refreshDistroLists = async (opts: { signal?: AbortSignal }) => { @@ -170,10 +227,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa setRuntime(id, { kind: "failed", message }) logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal }) }) - void refreshOpencodeCheck(item.config.distro).catch((error) => { - const message = error instanceof Error ? error.message : String(error) - logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message }) - }) + refreshOpencodeCheckBackground(id, item.config.distro) logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -225,6 +279,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa async initialize() { refreshFromStore() + void refreshOpencodeChecks() for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id) }, @@ -311,7 +366,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa id, distro, } - persistServers([...readPersistedServers(), config]) + persistServers([...readServers(), config]) setState({ servers: [...state.servers, { config, runtime: { kind: "starting" } }], }) @@ -323,7 +378,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa const distro = state.servers.find((item) => item.config.id === id)?.config.distro invalidateStartAttempt(id) await stopServerInternal(id) - const remaining = readPersistedServers().filter((item) => item.id !== id) + const remaining = readServers().filter((item) => item.id !== id) persistServers(remaining) setState({ servers: state.servers.filter((item) => item.config.id !== id), @@ -371,6 +426,10 @@ function readPersistedServers(): WslServerConfig[] { return [] } +function writePersistedServers(servers: WslServerConfig[]) { + getStore().set(WSL_SERVERS_KEY, { servers }) +} + function normalizePersistedServer(value: unknown): WslServerConfig[] { if (!value || typeof value !== "object") return [] const record = value as Record diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index ece1833f7bfa..84323bad2008 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -6,8 +6,7 @@ "license": "MIT", "private": true, "scripts": { - "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit" }, "exports": { diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index bb36d3df195c..a97cd31e56f2 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -27,8 +27,7 @@ "access": "public" }, "scripts": { - "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit", "build": "bun ./script/build.ts", "verify:package": "bun ./script/verify-package.ts" diff --git a/packages/llm/package.json b/packages/llm/package.json index eea583f71a20..d6d981925ae1 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -7,7 +7,7 @@ "private": true, "scripts": { "setup:recording-env": "bun run script/setup-recording-env.ts", - "test": "bun test --timeout 30000", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit" }, "exports": { diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 5331d68bb98c..a37cd2c9a758 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -14,7 +14,7 @@ import { type ProviderMetadata, type ToolCallPart, type ToolDefinition, - type ToolResultContentPart, + type ToolContent, type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" @@ -321,10 +321,10 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me // Tool results may carry structured text/images. Keep media as provider-native // content instead of JSON-stringifying base64 into a prompt string. const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* ( - item: ToolResultContentPart, + item: ToolContent, ) { if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock - const media = yield* ProviderShared.validateMedia( + const media = yield* ProviderShared.validateToolFile( "Anthropic Messages", item, new Set(ProviderShared.IMAGE_MIMES), @@ -344,7 +344,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte // with existing cassettes and provider expectations. if (part.result.type !== "content") return ProviderShared.toolResultText(part) // Preserve the narrowed array element type when compiled through a consumer package. - const content: ReadonlyArray = part.result.value + const content: ReadonlyArray = part.result.value return yield* Effect.forEach(content, lowerToolResultContentItem) }) diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 72143a48fbb7..42dcaef54005 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -269,7 +269,12 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent content.push({ text: item.text }) continue } - const media = yield* BedrockMedia.lower(item) + const media = yield* BedrockMedia.lower({ + type: "media", + mediaType: item.mime, + data: item.uri, + filename: item.name, + }) if (!("image" in media)) return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results") content.push(media) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 572807052441..c8fa34b50965 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -14,7 +14,7 @@ import { type TextPart, type ToolCallPart, type ToolDefinition, - type ToolResultContentPart, + type ToolContent, } from "../schema" import { JsonObject, optionalArray, ProviderShared } from "./shared" import { GeminiToolSchema } from "./utils/gemini-tool-schema" @@ -262,7 +262,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR }) continue } - const content: ReadonlyArray = part.result.value + const content: ReadonlyArray = part.result.value const text = content.filter((item) => item.type === "text").map((item) => item.text) parts.push({ functionResponse: { @@ -275,7 +275,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR }) for (const item of content) { if (item.type === "text") continue - const media = yield* ProviderShared.validateMedia("Gemini", item, IMAGE_MIMES) + const media = yield* ProviderShared.validateToolFile("Gemini", item, IMAGE_MIMES) parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } }) } } diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 65c41031bbe3..e37eec95ecda 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -14,7 +14,7 @@ import { type TextPart, type ToolCallPart, type ToolDefinition, - type ToolResultContentPart, + type ToolContent, } from "../schema" import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" @@ -200,9 +200,7 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({ }, }) -const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* ( - part: Extract, -) { +const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) { const media = yield* ProviderShared.validateMedia("OpenAI Chat", part, IMAGE_MIMES) return { type: "image_url" as const, image_url: { url: media.dataUrl } } }) @@ -271,13 +269,15 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) }) continue } - const content: ReadonlyArray = part.result.value + const content: ReadonlyArray = part.result.value const text = content.filter((item) => item.type === "text").map((item) => item.text) messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") }) - const media = content.filter( - (item): item is Extract => item.type === "media", + const files = content.filter((item) => item.type === "file") + images.push( + ...(yield* Effect.forEach(files, (item) => + lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }), + )), ) - images.push(...(yield* Effect.forEach(media, lowerMedia))) } return { messages, images } }) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 3616ef6cc882..b8a955640ed2 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -14,7 +14,7 @@ import { type TextPart, type ToolCallPart, type ToolDefinition, - type ToolResultContentPart, + type ToolContent, type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" @@ -318,10 +318,10 @@ const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* // Tool results may carry structured text/images. Keep media as provider-native // content instead of JSON-stringifying base64 into a prompt string. const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* ( - item: ToolResultContentPart, + item: ToolContent, ) { if (item.type === "text") return { type: "input_text" as const, text: item.text } - const media = yield* ProviderShared.validateMedia( + const media = yield* ProviderShared.validateToolFile( "OpenAI Responses", item, new Set(ProviderShared.IMAGE_MIMES), @@ -334,7 +334,7 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput") // compatibility with existing cassettes and provider expectations. if (part.result.type !== "content") return ProviderShared.toolResultText(part) // Preserve the narrowed array element type when compiled through a consumer package. - const content: ReadonlyArray = part.result.value + const content: ReadonlyArray = part.result.value return yield* Effect.forEach(content, lowerToolResultContentItem) }) diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 774a7b7a65f8..4a1fed55398a 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -9,6 +9,7 @@ import { type ContentPart, type LLMRequest, type MediaPart, + type ToolFileContent, type TextPart, type ToolResultPart, } from "../schema" @@ -233,6 +234,9 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function* return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia }) +export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet) => + validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes) + export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") export const toolResultText = (part: ToolResultPart) => { diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index 8a0a91b56b50..b160f2d4a495 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -39,68 +39,24 @@ export const MediaPart = Schema.Struct({ }).annotate({ identifier: "LLM.Content.Media" }) export type MediaPart = Schema.Schema.Type -export const ToolResultMediaPart = Schema.Struct({ - type: Schema.Literal("media"), - mediaType: Schema.String, - data: Schema.String, - filename: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}).annotate({ identifier: "LLM.ToolResult.Media" }) -export type ToolResultMediaPart = Schema.Schema.Type - -export const ToolResultContentPart = Schema.Union([TextPart, ToolResultMediaPart]) -export type ToolResultContentPart = Schema.Schema.Type - -export class ToolTextContent extends Schema.Class("Tool.TextContent")({ +export const ToolTextContent = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String, -}) {} +}).annotate({ identifier: "Tool.TextContent" }) +export type ToolTextContent = typeof ToolTextContent.Type -export const ToolFileSource = Schema.Union([ - Schema.Struct({ type: Schema.Literal("data"), data: Schema.String }), - Schema.Struct({ type: Schema.Literal("url"), url: Schema.String }), - Schema.Struct({ type: Schema.Literal("file"), uri: Schema.String }), -]).pipe(Schema.toTaggedUnion("type")) -export type ToolFileSource = Schema.Schema.Type - -export class ToolFileContent extends Schema.Class("Tool.FileContent")({ +export const ToolFileContent = Schema.Struct({ type: Schema.Literal("file"), - source: ToolFileSource, + uri: Schema.String, mime: Schema.String, name: Schema.optional(Schema.String), -}) {} +}).annotate({ identifier: "Tool.FileContent" }) +export type ToolFileContent = typeof ToolFileContent.Type /** Ordered, provider-independent content shown to models and UIs after a tool succeeds. */ export const ToolContent = Schema.Union([ToolTextContent, ToolFileContent]).pipe(Schema.toTaggedUnion("type")) export type ToolContent = Schema.Schema.Type -export const toolText = (value: ConstructorParameters[0]) => new ToolTextContent(value) -export const toolFile = (value: ConstructorParameters[0]) => new ToolFileContent(value) - -const inlineData = (uri: string) => { - if (!uri.startsWith("data:")) return undefined - const match = /^data:[^;,]+;base64,(.*)$/s.exec(uri) - if (!match) throw new Error("Tool file data URI must contain raw base64 bytes") - return match[1]! -} - -const legacyInlineData = (value: string) => { - const data = inlineData(value) - if (data !== undefined) return data - if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return value - throw new Error("Legacy tool-result media must contain raw base64 bytes or a base64 data URI") -} - -/** Convert a legacy attachment URI without guessing unknown string semantics. */ -export const toolFileSourceFromUri = (uri: string): ToolFileSource => { - const data = inlineData(uri) - if (data !== undefined) return { type: "data", data } - const url = URL.parse(uri) - if (url?.protocol === "file:") return { type: "file", uri } - if (url?.protocol === "http:" || url?.protocol === "https:") return { type: "url", url: uri } - throw new Error(`Unsupported tool file URI: ${uri}`) -} - const isToolResultValue = (value: unknown): value is ToolResultValue => isRecord(value) && (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") && @@ -122,7 +78,7 @@ export const ToolResultValue = Object.assign( }), Schema.Struct({ type: Schema.Literal("content"), - value: Schema.Array(ToolResultContentPart), + value: Schema.Array(ToolContent), }), ]).annotate({ identifier: "LLM.ToolResult" }), { @@ -147,34 +103,15 @@ export const ToolOutput = Object.assign( content: Schema.Array(ToolContent), }).annotate({ identifier: "LLM.ToolOutput" }), { - make: (structured: unknown, content: ReadonlyArray = []): ToolOutput => ({ - structured, - content: content.map((item) => - item.type === "text" - ? toolText({ type: "text", text: item.text }) - : toolFile({ type: "file", source: item.source, mime: item.mime, name: item.name }), - ), - }), + make: (structured: unknown, content: ReadonlyArray = []): ToolOutput => ({ structured, content }), fromResultValue: (result: ToolResultValue): ToolOutput | undefined => { switch (result.type) { case "json": return { structured: result.value, content: [] } case "text": - return { structured: {}, content: [toolText({ type: "text", text: toolResultText(result.value) })] } + return { structured: {}, content: [{ type: "text", text: toolResultText(result.value) }] } case "content": - return { - structured: {}, - content: result.value.map((item) => - item.type === "text" - ? toolText({ type: "text", text: item.text }) - : toolFile({ - type: "file", - source: { type: "data", data: legacyInlineData(item.data) }, - mime: item.mediaType, - name: item.filename, - }), - ), - } + return { structured: {}, content: result.value } case "error": return undefined } @@ -183,21 +120,7 @@ export const ToolOutput = Object.assign( if (output.content.length === 0) return { type: "json", value: output.structured } if (output.content.length === 1 && output.content[0]?.type === "text") return { type: "text", value: output.content[0].text } - const unsupported = output.content.find((item) => item.type === "file" && item.source.type !== "data") - if (unsupported?.type === "file") - return { - type: "error", - value: `Tool file source "${unsupported.source.type}" must be materialized to inline data before provider conversion`, - } - return { - type: "content", - value: output.content.map((item) => { - if (item.type === "text") return { type: "text", text: item.text } - if (item.source.type !== "data") - throw new Error("Unmaterialized tool file source reached provider conversion") - return { type: "media", mediaType: item.mime, data: item.source.data, filename: item.name } - }), - } + return { type: "content", value: output.content } }, }, ) diff --git a/packages/llm/src/tool.ts b/packages/llm/src/tool.ts index cebb293bb17e..11ed9854ca38 100644 --- a/packages/llm/src/tool.ts +++ b/packages/llm/src/tool.ts @@ -5,7 +5,7 @@ import type { ToolDefinition as ToolDefinitionClass, ToolOutput as ToolOutputType, } from "./schema" -import { ToolDefinition, ToolFailure, ToolOutput, toolText } from "./schema" +import { ToolDefinition, ToolFailure, ToolOutput } from "./schema" /** * Schema constraint for tool parameters / success values: no decoding or @@ -245,7 +245,7 @@ const project = ( ToolOutput.make( toStructuredOutput?.(output) ?? output, toModelOutput?.({ callID, parameters, output }) ?? - (typeof output === "string" ? [toolText({ type: "text", text: output })] : []), + (typeof output === "string" ? [{ type: "text", text: output }] : []), ) export { ToolFailure } diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index f9f4650c5aed..dabf512f6b69 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -235,7 +235,7 @@ describe("Anthropic Messages route", () => { resultType: "content", result: [ { type: "text", text: "Image read successfully" }, - { type: "media", mediaType: "image/png", data: "AAECAw==" }, + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }, ], }), ], @@ -262,7 +262,7 @@ describe("Anthropic Messages route", () => { id: "call_1", name: "screenshot", resultType: "content", - result: [{ type: "media", mediaType: "image/jpeg", data: "/9j/AA==" }], + result: [{ type: "file", uri: "data:image/jpeg;base64,/9j/AA==", mime: "image/jpeg" }], }), ], cache: "none", @@ -287,7 +287,7 @@ describe("Anthropic Messages route", () => { id: "call_1", name: "fetch", resultType: "content", - result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }], + result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }], }), ], cache: "none", diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 6cdfbee16950..23483dda2518 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -189,7 +189,7 @@ describe("Bedrock Converse route", () => { type: "content", value: [ { type: "text", text: "Screenshot captured." }, - { type: "media", mediaType: "image/png", data: "AAAA" }, + { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }, ], }, }), diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index e997e4695c50..d30742c47d35 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -124,7 +124,7 @@ describe("Gemini route", () => { type: "content", value: [ { type: "text", text: "Image read successfully" }, - { type: "media", mediaType: "image/png", data: "AAECAw==", filename: "pixel.png" }, + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }, ], }, }), @@ -163,7 +163,7 @@ describe("Gemini route", () => { name: "read", result: { type: "content", - value: [{ type: "media", mediaType: "image/jpeg", data: "data:image/jpeg;base64,/9j/" }], + value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }], }, }), ], diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 584c99ea3d46..9966b92e3dea 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -238,7 +238,7 @@ describe("OpenAI Chat route", () => { type: "content", value: [ { type: "text", text: "Image read successfully" }, - { type: "media", mediaType: "image/png", data: "AAECAw==", filename: "pixel.png" }, + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }, ], }, }), @@ -285,13 +285,19 @@ describe("OpenAI Chat route", () => { type: "tool-result", id: "call_1", name: "read", - result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAEC" }] }, + result: { + type: "content", + value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }], + }, }, { type: "tool-result", id: "call_2", name: "read", - result: { type: "content", value: [{ type: "media", mediaType: "image/jpeg", data: "/9j/" }] }, + result: { + type: "content", + value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }], + }, }, ], }), @@ -321,12 +327,18 @@ describe("OpenAI Chat route", () => { Message.tool({ id: "call_1", name: "read", - result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAEC" }] }, + result: { + type: "content", + value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }], + }, }), Message.tool({ id: "call_2", name: "read", - result: { type: "content", value: [{ type: "media", mediaType: "image/webp", data: "UklG" }] }, + result: { + type: "content", + value: [{ type: "file", uri: "data:image/webp;base64,UklG", mime: "image/webp" }], + }, }), Message.system("Inspect both images."), ], diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 0faba5ac17e4..717a7e8024f3 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -377,7 +377,7 @@ describe("OpenAI Responses route", () => { resultType: "content", result: [ { type: "text", text: "Image read successfully" }, - { type: "media", mediaType: "image/png", data: "AAECAw==" }, + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }, ], }), ], @@ -403,7 +403,7 @@ describe("OpenAI Responses route", () => { id: "call_1", name: "screenshot", resultType: "content", - result: [{ type: "media", mediaType: "image/png", data: "AAECAw==" }], + result: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }], }), ], }), @@ -427,7 +427,7 @@ describe("OpenAI Responses route", () => { id: "call_1", name: "fetch", resultType: "content", - result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }], + result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }], }), ], }), diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 545a3b983ab3..cac348da6590 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -388,7 +388,7 @@ const runImageToolResultScenario = (context: GoldenScenarioContext) => resultType: "content", result: [ { type: "text", text: "Image read successfully" }, - { type: "media", mediaType: "image/png", data: image }, + { type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png" }, ], }), ], diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 4900fb819715..c69456e425d1 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -9,7 +9,6 @@ import { ToolChoice, ToolContent, ToolOutput, - toolFileSourceFromUri, toDefinitions, } from "../src" import { Auth, LLMClient } from "../src/route" @@ -217,7 +216,7 @@ describe("LLMClient tools", () => { execute: () => Effect.succeed({ mime: "image/png", data: "AAECAw==" }), toStructuredOutput: (output) => ({ mime: output.mime }), toModelOutput: ({ output }) => [ - { type: "file", source: { type: "data", data: output.data }, mime: output.mime }, + { type: "file", uri: `data:${output.mime};base64,${output.data}`, mime: output.mime }, ], }) @@ -228,90 +227,79 @@ describe("LLMClient tools", () => { expect(dispatched.output).toEqual({ structured: { mime: "image/png" }, - content: [{ type: "file", source: { type: "data", data: "AAECAw==" }, mime: "image/png" }], + content: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }], }) }), ) - it.effect("models canonical tool files with explicit data, url, and file sources", () => + it.effect("models canonical tool files with URIs", () => Effect.sync(() => { const decode = Schema.decodeUnknownSync(ToolContent) - expect(decode({ type: "file", source: { type: "data", data: "AAAA" }, mime: "image/png" })).toEqual({ + expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({ type: "file", - source: { type: "data", data: "AAAA" }, + uri: "data:image/png;base64,AAAA", mime: "image/png", }) - expect( - decode({ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" }), - ).toEqual({ + expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({ type: "file", - source: { type: "url", url: "https://example.test/image.png" }, + uri: "https://example.test/image.png", mime: "image/png", }) - expect( - decode({ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" }), - ).toEqual({ + expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({ type: "file", - source: { type: "file", uri: "file:///tmp/image.png" }, + uri: "file:///tmp/image.png", mime: "image/png", }) }), ) - it.effect("converts canonical data files deliberately and rejects unmaterialized sources", () => + it.effect("preserves canonical tool file URIs", () => Effect.sync(() => { expect( ToolOutput.toResultValue( - ToolOutput.make({}, [{ type: "file", source: { type: "data", data: "AAAA" }, mime: "image/png" }]), + ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]), ), - ).toEqual({ type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAAA" }] }) + ).toEqual({ + type: "content", + value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }], + }) expect( ToolOutput.toResultValue( - ToolOutput.make({}, [ - { type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" }, - ]), + ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]), ), ).toEqual({ - type: "error", - value: 'Tool file source "url" must be materialized to inline data before provider conversion', + type: "content", + value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], }) expect( ToolOutput.toResultValue( - ToolOutput.make({}, [ - { type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" }, - ]), + ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]), ), ).toEqual({ - type: "error", - value: 'Tool file source "file" must be materialized to inline data before provider conversion', - }) - expect(toolFileSourceFromUri("data:image/png;base64,AAAA")).toEqual({ type: "data", data: "AAAA" }) - expect(toolFileSourceFromUri("https://example.test/image.png")).toEqual({ - type: "url", - url: "https://example.test/image.png", + type: "content", + value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }], }) - expect(toolFileSourceFromUri("file:///tmp/image.png")).toEqual({ type: "file", uri: "file:///tmp/image.png" }) - expect(() => toolFileSourceFromUri("opaque-value")).toThrow("Unsupported tool file URI") - expect(() => + expect( ToolOutput.fromResultValue({ type: "content", - value: [{ type: "media", mediaType: "image/png", data: "https://example.test/image.png" }], + value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], }), - ).toThrow("Legacy tool-result media must contain raw base64 bytes or a base64 data URI") + ).toEqual({ + structured: {}, + content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], + }) }), ) - it.effect("settles projected url files as materialization errors", () => + it.effect("settles projected URL files as canonical tool results", () => Effect.gen(function* () { const remote = Tool.make({ description: "Return a remote file.", parameters: Schema.Struct({}), success: Schema.Struct({ ok: Schema.Boolean }), execute: () => Effect.succeed({ ok: true }), - toModelOutput: () => [ - { type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" }, - ], + toModelOutput: () => [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], }) const dispatched = yield* ToolRuntime.dispatch( @@ -319,12 +307,15 @@ describe("LLMClient tools", () => { LLMEvent.toolCall({ id: "call_remote", name: "remote", input: {} }), ) - expect(dispatched.output).toBeUndefined() + expect(dispatched.output).toEqual({ + structured: { ok: true }, + content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], + }) expect(dispatched.result).toEqual({ - type: "error", - value: 'Tool file source "url" must be materialized to inline data before provider conversion', + type: "content", + value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }], }) - expect(dispatched.events.map((event) => event.type)).toEqual(["tool-error", "tool-result"]) + expect(dispatched.events.map((event) => event.type)).toEqual(["tool-result"]) }), ) @@ -357,7 +348,7 @@ describe("LLMClient tools", () => { type: "content" as const, value: [ { type: "text" as const, text: "Screenshot captured." }, - { type: "media" as const, mediaType: "image/png", data: "AAAA" }, + { type: "file" as const, uri: "data:image/png;base64,AAAA", mime: "image/png" }, ], }), }) @@ -379,7 +370,7 @@ describe("LLMClient tools", () => { type: "content", value: [ { type: "text", text: "Screenshot captured." }, - { type: "media", mediaType: "image/png", data: "AAAA" }, + { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }, ], }, }) diff --git a/packages/opencode/BUN_SHELL_MIGRATION_PLAN.md b/packages/opencode/BUN_SHELL_MIGRATION_PLAN.md deleted file mode 100644 index 569045c06401..000000000000 --- a/packages/opencode/BUN_SHELL_MIGRATION_PLAN.md +++ /dev/null @@ -1,136 +0,0 @@ -# Bun shell migration plan - -Practical phased replacement of Bun `$` calls. - -## Goal - -Replace runtime Bun shell template-tag usage in `packages/opencode/src` with a unified `Process` API in `util/process.ts`. - -Keep behavior stable while improving safety, testability, and observability. - -Current baseline from audit: - -- 143 runtime command invocations across 17 files -- 84 are git commands -- Largest hotspots: - - `src/cli/cmd/github.ts` (33) - - `src/worktree/index.ts` (22) - - `src/lsp/server.ts` (21) - - `src/installation/index.ts` (20) - - `src/snapshot/index.ts` (18) - -## Decisions - -- Extend `src/util/process.ts` (do not create a separate exec module). -- Proceed with phased migration for both git and non-git paths. -- Keep plugin `$` compatibility in 1.x and remove in 2.0. - -## Non-goals - -- Do not remove plugin `$` compatibility in this effort. -- Do not redesign command semantics beyond what is needed to preserve behavior. - -## Constraints - -- Keep migration phased, not big-bang. -- Minimize behavioral drift. -- Keep these explicit shell-only exceptions: - - `src/session/prompt.ts` raw command execution - - worktree start scripts in `src/worktree/index.ts` - -## Process API proposal (`src/util/process.ts`) - -Add higher-level wrappers on top of current spawn support. - -Core methods: - -- `Process.run(cmd, opts)` -- `Process.text(cmd, opts)` -- `Process.lines(cmd, opts)` -- `Process.status(cmd, opts)` -- `Process.shell(command, opts)` for intentional shell execution - -Git helpers: - -- `Process.git(args, opts)` -- `Process.gitText(args, opts)` - -Shared options: - -- `cwd`, `env`, `stdin`, `stdout`, `stderr`, `abort`, `timeout`, `kill` -- `allowFailure` / non-throw mode -- optional redaction + trace metadata - -Standard result shape: - -- `code`, `stdout`, `stderr`, `duration_ms`, `cmd` -- helpers like `text()` and `arrayBuffer()` where useful - -## Phased rollout - -### Phase 0: Foundation - -- Implement Process wrappers in `src/util/process.ts`. -- Refactor `src/util/git.ts` to use Process only. -- Add tests for exit handling, timeout, abort, and output capture. - -### Phase 1: High-impact hotspots - -Migrate these first: - -- `src/cli/cmd/github.ts` -- `src/worktree/index.ts` -- `src/lsp/server.ts` -- `src/installation/index.ts` -- `src/snapshot/index.ts` - -Within each file, migrate git paths first where applicable. - -### Phase 2: Remaining git-heavy files - -Migrate git-centric call sites to `Process.git*` helpers: - -- `../core/src/filesystem.ts` -- `src/project/vcs.ts` -- `../core/src/filesystem/watcher.ts` -- `src/storage/storage.ts` -- `src/cli/cmd/pr.ts` - -### Phase 3: Remaining non-git files - -Migrate residual non-git usages: - -- `src/cli/cmd/tui/util/clipboard.ts` -- `src/util/archive.ts` -- `../core/src/filesystem/ripgrep.ts` -- `src/tool/bash.ts` -- `src/cli/cmd/uninstall.ts` - -### Phase 4: Stabilize - -- Remove dead wrappers and one-off patterns. -- Keep plugin `$` compatibility isolated and documented as temporary. -- Create linked 2.0 task for plugin `$` removal. - -## Validation strategy - -- Unit tests for new `Process` methods and options. -- Integration tests on hotspot modules. -- Smoke tests for install, snapshot, worktree, and GitHub flows. -- Regression checks for output parsing behavior. - -## Risk mitigation - -- File-by-file PRs with small diffs. -- Preserve behavior first, simplify second. -- Keep shell-only exceptions explicit and documented. -- Add consistent error shaping and logging at Process layer. - -## Definition of done - -- Runtime Bun `$` usage in `packages/opencode/src` is removed except: - - approved shell-only exceptions - - temporary plugin compatibility path (1.x) -- Git paths use `Process.git*` consistently. -- CI and targeted smoke tests pass. -- 2.0 issue exists for plugin `$` removal. diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d1bbf9dd1506..8150e1e6f450 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -7,8 +7,7 @@ "private": true, "scripts": { "typecheck": "tsgo --noEmit", - "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun test --timeout 30000 --only-failures", "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip", "bench:test": "bun run script/bench-test-suite.ts", "profile:test": "bun run script/profile-test-files.ts", @@ -80,7 +79,7 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/sdk": "1.27.1", + "@modelcontextprotocol/sdk": "1.29.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -122,6 +121,7 @@ "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", + "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", diff --git a/packages/opencode/script/bench-search.ts b/packages/opencode/script/bench-search.ts index 91cae6ba7ac8..42afe7220809 100644 --- a/packages/opencode/script/bench-search.ts +++ b/packages/opencode/script/bench-search.ts @@ -91,9 +91,7 @@ console.log("--- Search service (warm) ---") for (const q of FILE_QUERIES) { const t = performance.now() const r = await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: q, limit: FILE_LIMIT }))) - console.log( - `[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r?.length ?? "undefined (cache fallback)"} results)`, - ) + console.log(`[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} results)`) } for (const q of GREP_QUERIES) { diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 9d9f7e4a2882..948eb3c06331 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -1,3 +1,5 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { @@ -456,4 +458,6 @@ export const layer: Layer.Layer & Partial> type GlobalEventEnvelope = { @@ -59,9 +56,8 @@ export class Subscription { start() { if (this.started) return this.started = true - this.run().catch((error: unknown) => { + this.run().catch(() => { if (this.abort.signal.aborted) return - log.error("event subscription failed", { error }) }) } @@ -125,9 +121,7 @@ export class Subscription { for await (const event of events.stream) { if (this.abort.signal.aborted) return if (!event.payload) continue - await this.handle(event.payload).catch((error: unknown) => { - log.error("failed to handle event", { error, type: event.payload?.type }) - }) + await this.handle(event.payload).catch(() => {}) } if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000)) } @@ -214,10 +208,7 @@ export class Subscription { { throwOnError: true }, ) .then((response) => response.data) - .catch((error: unknown) => { - log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId }) - return undefined - }) + .catch(() => undefined) if (!message) return const part = message.parts.find((item) => item.id === partId) @@ -330,6 +321,7 @@ export class Subscription { ...pendingToolCall({ toolCallId: part.callID, toolName: part.tool, + state: part.state, }), }, }) diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index cefd2a34f361..357754e093e9 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -1,5 +1,4 @@ import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk" -import * as Log from "@opencode-ai/core/util/log" import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { exists, readText } from "@/util/filesystem" @@ -7,8 +6,6 @@ import type { ACPSession } from "./session" import { toLocations, toToolKind, type ToolInput } from "./tool" import { Effect } from "effect" -const log = Log.create({ service: "acp-permission" }) - type PermissionEvent = Extract type Reply = "once" | "always" | "reject" type Connection = Partial> @@ -35,9 +32,7 @@ export class Handler { const previous = this.queues.get(permission.sessionID) ?? Promise.resolve() const next = previous .then(() => this.process(event)) - .catch((error: unknown) => { - log.error("failed to handle permission", { error, permissionID: permission.id }) - }) + .catch(() => {}) .finally(() => { if (this.queues.get(permission.sessionID) === next) { this.queues.delete(permission.sessionID) @@ -52,10 +47,6 @@ export class Handler { if (!session) return if (!this.input.connection.requestPermission) { - log.error("ACP connection cannot request permission", { - permissionID: permission.id, - sessionID: permission.sessionID, - }) await this.reply(permission.id, "reject", session.cwd) return } @@ -73,12 +64,7 @@ export class Handler { }, options: permissionOptions, }) - .catch(async (error: unknown) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) + .catch(async () => { await this.reply(permission.id, "reject", session.cwd) return undefined }) @@ -92,13 +78,7 @@ export class Handler { } if (permission.permission === "edit") { - await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => { - log.error("failed to write proposed edit through ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - }) + await this.writeProposedEdit(session.id, permission.metadata).catch(() => {}) } await this.reply(permission.id, reply, session.cwd) @@ -120,7 +100,6 @@ export class Handler { const content = (await exists(filepath)) ? await readText(filepath) : "" const next = applyPatch(content, diff) if (next === false) { - log.error("Failed to apply unified diff (context mismatch)") return } diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 4afcfad1ced1..36e8375f5cc4 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -30,7 +30,6 @@ import { type SetSessionModeResponse, } from "@agentclientprotocol/sdk" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import * as Log from "@opencode-ai/core/util/log" import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPError from "./error" @@ -47,7 +46,6 @@ import { Provider } from "@/provider/provider" import type { Command } from "@/command" export const AuthMethodID = "opencode-login" -const log = Log.create({ service: "acp-service" }) export type Error = ACPError.Error type ServiceConnection = Pick & @@ -333,9 +331,7 @@ export function make(input: { "session", ).pipe( Effect.catch((error) => - Effect.sync(() => { - log.error("failed to abort ACP backing session", { error, sessionID: current.id }) - }), + Effect.logError("failed to abort ACP backing session", { error: error, sessionID: current.id }), ), ) }) @@ -610,10 +606,7 @@ function makeUsageService(sdk: OpencodeClient) { ) as Record return UsageService.findContextLimit(providers, params.providerID, params.modelID) }) - .catch((error: unknown) => { - log.error("failed to get providers for usage context limit", { error }) - return undefined - }) + .catch(() => undefined) limits.set(key, next) return yield* Effect.promise(() => next) }, @@ -633,10 +626,7 @@ function makeUsageService(sdk: OpencodeClient) { ).pipe( Effect.map((messages) => messages as readonly UsageService.SessionMessage[]), Effect.catch((error) => - Effect.sync(() => { - log.error("failed to fetch messages for usage update", { error }) - return undefined - }), + Effect.logError("failed to fetch messages for usage update", { error: error }).pipe(Effect.as(undefined)), ), ) if (!messages) return @@ -662,9 +652,7 @@ function makeUsageService(sdk: OpencodeClient) { cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, }, }) - .catch((error) => { - log.error("failed to send usage update", { error }) - }), + .catch(() => {}), ) }) @@ -681,9 +669,7 @@ function replayMessages(subscription: ACPEvent.Subscription | undefined, message if (!subscription) return Effect.void return Effect.promise(async () => { for (const message of messages) { - await subscription.replayMessage(message).catch((error: unknown) => { - log.error("failed to replay ACP message", { error, messageID: message.info.id }) - }) + await subscription.replayMessage(message).catch(() => {}) } }) } diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts index 0e8b4f098501..4c39c0eff1fd 100644 --- a/packages/opencode/src/acp/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -118,14 +118,18 @@ export function completedToolContent(toolName: string, state: CompletedToolState return content } -export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall { +export function pendingToolCall(input: { + readonly toolCallId: string + readonly toolName: string + readonly state: { readonly input: ToolInput; readonly title?: string } +}): ToolCall { return { toolCallId: input.toolCallId, - title: input.toolName, + title: input.state.title || input.toolName, kind: toToolKind(input.toolName), status: "pending", - locations: [], - rawInput: {}, + locations: toLocations(input.toolName, input.state.input), + rawInput: input.state.input, } } diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index a7af8cc9257f..be64114bb820 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -1,5 +1,4 @@ import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk" -import * as Log from "@opencode-ai/core/util/log" import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2" import { InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" @@ -8,8 +7,6 @@ import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" -const log = Log.create({ service: "acp-usage" }) - export type AssistantTokenCost = Pick export type AssistantMessage = AssistantTokenCost & @@ -157,10 +154,9 @@ export const layer = Layer.effect( contextLimitLoader.providers(input.directory).pipe( Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)), Effect.catch((error) => - Effect.sync(() => { - log.error("failed to get providers for usage context limit", { error }) - return undefined - }), + Effect.logError("failed to get providers for usage context limit", { error: error }).pipe( + Effect.as(undefined), + ), ), ), ) @@ -182,14 +178,13 @@ export const layer = Layer.effect( readonly sessionID: string readonly directory: string }) { - const messages = yield* messageLoader.messages({ sessionID: input.sessionID, directory: input.directory }).pipe( - Effect.catch((error) => - Effect.sync(() => { - log.error("failed to fetch messages for usage update", { error }) - return undefined - }), - ), - ) + const messages = yield* messageLoader + .messages({ sessionID: input.sessionID, directory: input.directory }) + .pipe( + Effect.catch((error) => + Effect.logError("failed to fetch messages for usage update", { error: error }).pipe(Effect.as(undefined)), + ), + ) if (!messages) return const message = latestAssistantMessage(messages) @@ -214,9 +209,7 @@ export const layer = Layer.effect( cost: { amount: totalSessionCost(messages), currency: "USD" }, }, }) - .catch((error) => { - log.error("failed to send usage update", { error }) - }), + .catch(() => {}), ) }) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index a04f5315b063..86dc417ca58b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Config } from "@/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" @@ -430,4 +431,6 @@ export const defaultLayer = layer.pipe( Layer.provide(Skill.defaultLayer), ) +export const node = LayerNode.make(layer, [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node]) + export * as Agent from "./agent" diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index f6d6001c7c53..5c18bc3caadf 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { NonNegativeInt } from "@opencode-ai/core/schema" @@ -93,4 +94,6 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer)) +export const node = LayerNode.make(layer, [FSUtil.node]) + export * as Auth from "." diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/background/job.ts index 4d888eb77ceb..f3511676d7f3 100644 --- a/packages/opencode/src/background/job.ts +++ b/packages/opencode/src/background/job.ts @@ -1,3 +1,4 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { BackgroundJob as CoreBackgroundJob } from "@opencode-ai/core/background-job" import { InstanceState } from "@/effect/instance-state" import { Effect, Layer } from "effect" @@ -33,4 +34,6 @@ export const layer = Layer.effect( export const defaultLayer = layer +export const node = LayerNode.make(layer, []) + export * as BackgroundJob from "./job" diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 6b335cc84502..da47d9579578 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -1,4 +1,3 @@ -import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" @@ -7,8 +6,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { ACPProfile } from "@/acp/profile" -const log = Log.create({ service: "acp-command" }) - export const AcpCommand = effectCmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", @@ -63,7 +60,7 @@ export const AcpCommand = effectCmd({ return agent.create(conn) }, stream) - log.info("setup connection") + yield* Effect.logInfo("setup connection") process.stdin.resume() yield* Effect.promise( () => diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index b40b423181fa..f2612b234c66 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -2,7 +2,6 @@ import { LSP } from "@/lsp/lsp" import { Effect } from "effect" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" -import * as Log from "@opencode-ai/core/util/log" import { EOL } from "os" export const LSPCommand = cmd({ @@ -33,7 +32,7 @@ export const SymbolsCommand = effectCmd({ describe: "search workspace symbols", builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) { - using _ = Log.Default.time("symbols") + yield* Effect.logInfo("symbols") const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) process.stdout.write(JSON.stringify(results, null, 2) + EOL) }), @@ -44,7 +43,7 @@ export const DocumentSymbolsCommand = effectCmd({ describe: "get symbols from a document", builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) { - using _ = Log.Default.time("document-symbols") + yield* Effect.logInfo("document-symbols") const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) process.stdout.write(JSON.stringify(results, null, 2) + EOL) }), diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index edf2e7350203..eef6e0556964 100644 --- a/packages/opencode/src/cli/cmd/debug/scrap.ts +++ b/packages/opencode/src/cli/cmd/debug/scrap.ts @@ -1,5 +1,4 @@ import { EOL } from "os" -import * as Log from "@opencode-ai/core/util/log" import { cmd } from "../cmd" export const ScrapCommand = cmd({ @@ -10,9 +9,7 @@ export const ScrapCommand = cmd({ const { Project } = await import("@/project/project") const { makeRuntime } = await import("@opencode-ai/core/effect/runtime") const runtime = makeRuntime(Project.Service, Project.defaultLayer) - const timer = Log.Default.time("scrap") const list = await runtime.runPromise((project) => project.list()) process.stdout.write(JSON.stringify(list, null, 2) + EOL) - timer.stop() }, }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index e9e6091ff1c0..18d033dadb3c 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -762,10 +762,15 @@ export const RunCommand = effectCmd({ if (!args.interactive) { const events = await client.event.subscribe() - loop(client, events).catch((e) => { + const completed = loop(client, events).catch((e) => { console.error(e) - process.exit(1) + process.exitCode = 1 }) + async function finish() { + if (args.attach) return + const error = await completed + if (error) process.exitCode = 1 + } if (args.command) { const result = await client.session.command({ @@ -779,7 +784,9 @@ export const RunCommand = effectCmd({ if (result.error) { if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) process.exitCode = 1 + return } + await finish() return } @@ -794,7 +801,9 @@ export const RunCommand = effectCmd({ if (result.error) { if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) process.exitCode = 1 + return } + await finish() return } diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts index d0d72ce00273..94450f124257 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -181,7 +181,7 @@ function showSubagent( callID: string label: string description: string - status: "running" | "completed" | "error" + status: "running" | "completed" | "cancelled" | "error" title?: string toolCalls?: number commits: StreamCommit[] diff --git a/packages/opencode/src/cli/cmd/run/entry.body.ts b/packages/opencode/src/cli/cmd/run/entry.body.ts index bb058e8a37f6..205e14ce25c6 100644 --- a/packages/opencode/src/cli/cmd/run/entry.body.ts +++ b/packages/opencode/src/cli/cmd/run/entry.body.ts @@ -79,6 +79,13 @@ function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { } export function entryFlags(commit: StreamCommit): EntryFlags { + if (commit.summary) { + return { + startOnNewLine: true, + trailingNewline: false, + } + } + if (commit.kind === "user") { return { startOnNewLine: true, @@ -156,6 +163,10 @@ export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolea } export function entryBody(commit: StreamCommit): RunEntryBody { + if (commit.summary) { + return RUN_ENTRY_NONE + } + const raw = cleanRunText(commit.text) if (commit.kind === "user") { diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index 90ba6fc6734c..27c1e989980c 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -14,6 +14,8 @@ type PanelEntry = RunFooterMenuItem & { type CommandEntry = | (PanelEntry & { action: "model" }) + | (PanelEntry & { action: "editor" }) + | (PanelEntry & { action: "skill" }) | (PanelEntry & { action: "queued" }) | (PanelEntry & { action: "subagent" }) | (PanelEntry & { action: "variant.cycle" }) @@ -33,6 +35,10 @@ type VariantEntry = PanelEntry & { current: boolean } +type SkillEntry = PanelEntry & { + name: string +} + type SubagentEntry = PanelEntry & { sessionID: string current: boolean @@ -107,6 +113,10 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) { return "done" } + if (status === "cancelled") { + return "cancelled" + } + if (status === "error") { return "error" } @@ -203,94 +213,129 @@ function PanelShell(props: { inputRef: (input: InputRenderable) => void onQuery: (query: string) => void children: JSX.Element + dark?: boolean + chrome?: "default" | "minimal" }) { - return ( - + const background = () => (props.dark ? props.theme().shade : props.theme().surface) + const minimal = () => props.chrome === "minimal" + const content = ( + <> + + + + {props.title} + + {props.countVisible !== false ? ( + + {countLabel(props.count, props.total, props.query)} + + ) : null} + + + esc + + + - + { + props.inputRef(input) + input.traits = { status: "FILTER" } + queueMicrotask(() => { + if (!input.isDestroyed) { + input.focus() + } + }) + }} + /> + + + + {props.children} + + + ) + return ( + + {minimal() ? ( + + {content} + + ) : ( - - {props.title} - - {props.countVisible !== false ? ( - - {countLabel(props.count, props.total, props.query)} - - ) : null} - - - esc - + {content} - + )} + {minimal() ? ( - { - props.inputRef(input) - input.traits = { status: "FILTER" } - queueMicrotask(() => { - if (!input.isDestroyed) { - input.focus() - } - }) - }} + height={1} + border={["bottom"]} + borderColor={background()} + backgroundColor="transparent" + customBorderChars={HALF_BLOCK_BORDER} /> - - - {props.children} - - - + ) : ( - + customBorderChars={PANEL_BOTTOM_BORDER} + flexShrink={0} + > + + + )} ) } @@ -304,6 +349,8 @@ export function RunCommandMenuBody(props: { variantCycle: string onClose: () => void onModel: () => void + onEditor: () => void + onSkill: () => void onSubagent: () => void onQueued: () => void onVariant: () => void @@ -314,19 +361,67 @@ export function RunCommandMenuBody(props: { }) { let field: InputRenderable | undefined const [query, setQuery] = createSignal("") + const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) + const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length) const entries = createMemo(() => { - const builtins = ["new"] - return [ + const builtins = ["editor", "new"] + const session: CommandEntry[] = [ + { + action: "editor", + category: "Session", + display: "Open editor", + footer: "/editor", + keywords: "editor compose draft external editor", + }, + ...(props.subagents().length > 0 + ? [ + { + action: "subagent" as const, + category: "Session", + display: "View subagents", + footer: + activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`, + keywords: props + .subagents() + .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`) + .join(" "), + }, + ] + : []), + { + action: "slash", + category: "Session", + name: "new", + display: "New session", + footer: "/new", + keywords: "new session clear", + }, + ] + const prompt: CommandEntry[] = + props.commands() === undefined || skills().length > 0 + ? [ + { + action: "skill" as const, + category: "Prompt", + display: "Skills", + footer: "/skills", + keywords: `skill skills ${skills() + .map((item) => `${item.name} ${item.description ?? ""}`) + .join(" ")}`.trim(), + }, + ] + : [] + const agent: CommandEntry[] = [ { action: "model", - category: "Suggested", + category: "Agent", display: "Switch model", }, ...(props.queued().length > 0 ? [ { action: "queued" as const, - category: "Suggested", + category: "Agent", display: "Manage queued prompts", footer: `${props.queued().length} queued`, keywords: props @@ -336,23 +431,9 @@ export function RunCommandMenuBody(props: { }, ] : []), - ...(props.subagents().length > 0 - ? [ - { - action: "subagent" as const, - category: "Suggested", - display: "View subagents", - footer: `${props.subagents().length} active`, - keywords: props - .subagents() - .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`) - .join(" "), - }, - ] - : []), { action: "variant.cycle", - category: "Suggested", + category: "Agent", display: "Variant cycle", footer: props.variantCycle, keywords: "variant cycle", @@ -361,37 +442,36 @@ export function RunCommandMenuBody(props: { ? [ { action: "variant.list" as const, - category: "Suggested", + category: "Agent", display: "Switch model variant", keywords: `variant variants ${props.variants().join(" ")}`, }, ] : []), - { - action: "slash", - category: "Session", - name: "new", - display: "New session", - footer: "/new", - keywords: "new session clear", - }, - ...(props.commands() ?? []) - .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) - .map( - (item) => - ({ - action: "slash", - category: item.source === "mcp" ? "MCP Commands" : "Project Commands", - name: item.name, - display: item.name, - footer: `/${item.name}`, - keywords: - item.source === "mcp" - ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` - : `/${item.name} ${item.name} ${item.description ?? ""}`, - }) satisfies CommandEntry, - ) - .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)), + ] + const commands = (props.commands() ?? []) + .filter((item) => item.source !== "skill" && !builtins.includes(item.name)) + .map( + (item) => + ({ + action: "slash", + category: item.source === "mcp" ? "MCP Commands" : "Project Commands", + name: item.name, + display: item.name, + footer: `/${item.name}`, + keywords: + item.source === "mcp" + ? `/${item.name} ${item.name} mcp ${item.description ?? ""}` + : `/${item.name} ${item.name} ${item.description ?? ""}`, + }) satisfies CommandEntry, + ) + .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display)) + + return [ + ...session, + ...prompt, + ...agent, + ...commands, { action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" }, ] }) @@ -403,6 +483,16 @@ export function RunCommandMenuBody(props: { return } + if (item.action === "editor") { + props.onEditor() + return + } + + if (item.action === "skill") { + props.onSkill() + return + } + if (item.action === "subagent") { props.onSubagent() return @@ -471,6 +561,8 @@ export function RunCommandMenuBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > ) @@ -566,6 +660,8 @@ export function RunSubagentSelectBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > ) @@ -662,6 +759,8 @@ export function RunQueuedPromptSelectBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > + + ) +} + +export function RunSkillSelectBody(props: { + theme: Accessor + commands: Accessor + onClose: () => void + onSelect: (name: string) => void +}) { + let field: InputRenderable | undefined + const [query, setQuery] = createSignal("") + const entries = createMemo(() => + (props.commands() ?? []) + .filter((item) => item.source === "skill") + .map((item) => ({ + category: "", + display: item.name, + description: item.description?.replace(/\s+/g, " ").trim() || undefined, + keywords: `skill ${item.name} ${item.description ?? ""}`, + name: item.name, + })) + .sort((a, b) => a.display.localeCompare(b.display)), + ) + const items = createMemo(() => match(query(), entries())) + const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS }) + const select = () => { + const item = items()[menu.selected()] + if (!item) { + return + } + + props.onSelect(item.name) + } + + createEffect(() => { + query() + menu.reset() + }) + + useKeyboard((event) => { + if (event.defaultPrevented) { + return + } + + handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose }) + }) + + return ( + { + field = input + }} + onQuery={setQuery} + dark + chrome="minimal" + > + PANEL_LIST_ROWS} + limit={PANEL_LIST_ROWS} + empty={props.commands() ? "No skills found" : "Skills loading"} + border={false} + paddingLeft={PANEL_PAD} + paddingRight={PANEL_PAD} + grouped={false} + background /> ) @@ -759,6 +938,8 @@ export function RunVariantSelectBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > ) @@ -879,6 +1061,8 @@ export function RunModelSelectBody(props: { field = input }} onQuery={setQuery} + dark + chrome="minimal" > ) diff --git a/packages/opencode/src/cli/cmd/run/footer.menu.tsx b/packages/opencode/src/cli/cmd/run/footer.menu.tsx index c3770b27b04a..ef312aa0c6df 100644 --- a/packages/opencode/src/cli/cmd/run/footer.menu.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.menu.tsx @@ -1,7 +1,9 @@ /** @jsxImportSource @opentui/solid */ -import { TextAttributes } from "@opentui/core" +import { TextAttributes, type ColorInput } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { transparent, type RunFooterTheme } from "./theme" +import * as Locale from "@/util/locale" export const FOOTER_MENU_ROWS = 8 @@ -125,7 +127,10 @@ export function RunFooterMenu(props: { paddingLeft?: number paddingRight?: number grouped?: boolean + background?: boolean + headerColor?: ColorInput }) { + const term = useTerminalDimensions() const limit = () => props.limit ?? FOOTER_MENU_ROWS const border = () => props.border ?? true const [groupOffset, setGroupOffset] = createSignal(0) @@ -203,16 +208,36 @@ export function RunFooterMenu(props: { return " ".repeat(Math.max(1, descriptionColumn() - Bun.stringWidth(item.display))) } + const descriptionText = (item: RunFooterMenuItem) => { + if (!item.description) { + return + } + + const footerWidth = item.footer ? Bun.stringWidth(item.footer) + 1 : 0 + const available = + term().width - + (border() ? 1 : 0) - + (props.paddingLeft ?? 1) - + (props.paddingRight ?? 0) - + descriptionColumn() - + footerWidth - + 4 + return Locale.truncate(item.description, Math.max(12, available)) + } return ( {rows().length === 0 ? ( - + {border() ? ( ┃ @@ -223,7 +248,7 @@ export function RunFooterMenu(props: { flexShrink={1} paddingLeft={props.paddingLeft ?? 1} paddingRight={props.paddingRight ?? 0} - backgroundColor={props.theme().surface} + backgroundColor={props.background ? props.theme().shade : transparent} > {props.empty ?? "No matching items"} @@ -239,7 +264,12 @@ export function RunFooterMenu(props: { if (row.type === "header") { return ( - + {row.label} @@ -247,54 +277,71 @@ export function RunFooterMenu(props: { } const active = () => row.index === props.selected() - const inset = () => (active() ? 1 : 0) + const background = () => + active() + ? props.background + ? props.theme().selected + : props.theme().shade + : props.background + ? props.theme().shade + : transparent return ( - + {border() ? ( - - ┃ + + {active() ? "▌" : " "} ) : undefined} - - + + {row.item.display} - {row.item.description ? ( - - {descriptionPad(row.item)} - {row.item.description} - - ) : undefined} - {row.item.footer ? ( - - {row.item.footer} - + {row.item.description ? ( + <> + + {descriptionPad(row.item)} + + + {descriptionText(row.item)} + + ) : undefined} + {row.item.footer ? ( + + {row.item.footer} + + ) : undefined} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx index 2790a9e0b66c..2ad7e5e4a3dc 100644 --- a/packages/opencode/src/cli/cmd/run/footer.permission.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -29,6 +29,7 @@ import { permissionShift, type PermissionOption, } from "./permission.shared" +import { footerWidthPolicy } from "./footer.width" import { toolFiletype } from "./tool" import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme" import type { PermissionReply, RunDiffStyle } from "./types" @@ -140,7 +141,7 @@ export function RunPermissionBody(props: { const [state, setState] = createSignal(createPermissionBodyState(props.request.id)) const info = createMemo(() => permissionInfo(props.request)) const ft = createMemo(() => toolFiletype(info().file)) - const narrow = createMemo(() => dims().width < 80) + const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) const opts = createMemo(() => permissionOptions(state().stage)) const busy = createMemo(() => state().submitting) const title = createMemo(() => { @@ -257,7 +258,13 @@ export function RunPermissionBody(props: { }) return ( - + type Auto = RunFooterMenuItem & { @@ -53,6 +48,7 @@ type Auto = RunFooterMenuItem & { type SlashOption = RunFooterMenuItem & { kind: "slash" name: string + action?: "skill-menu" | "editor" } type PromptOption = Auto | SlashOption @@ -75,9 +71,11 @@ type PromptInput = { onSubmit: (input: RunPrompt) => boolean | Promise onCycle: () => void onInterrupt: () => boolean + onEditorOpen: (input: { value: string }) => Promise onInputClear: () => void onExitRequest?: () => boolean onExit: () => void + onSkillMenu: () => void onRows: (rows: number) => void onStatus: (text: string) => void } @@ -93,6 +91,7 @@ export type PromptState = { requestExit: () => boolean onSubmit: () => void submitText: (text: string) => void + openEditor: (input?: { value?: string }) => Promise onKeyDown: (event: KeyEvent) => void onContentChange: () => void replaceDraft: (text: string) => void @@ -109,6 +108,7 @@ function clonePrompt(prompt: RunPrompt): RunPrompt { text: prompt.text, parts: structuredClone(prompt.parts), ...(prompt.mode ? { mode: prompt.mode } : {}), + ...(prompt.command ? { command: prompt.command } : {}), } } @@ -182,17 +182,25 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) { return { type: "command" as const, command: { name: head.name, arguments: head.arguments } } } -export function hintFlags(width: number) { +function selectedCommand(text: string, command: RunPrompt["command"]) { + if (!command) { + return + } + + const head = slashHead(text) + if (!head || head.name !== command.name) { + return + } + return { - send: width >= HINT_BREAKPOINTS.send, - newline: width >= HINT_BREAKPOINTS.newline, - history: width >= HINT_BREAKPOINTS.history, - command: width >= HINT_BREAKPOINTS.command, + name: command.name, + arguments: head.arguments, } } export function RunPromptBody(props: { theme: () => RunFooterTheme + background: () => ColorInput placeholder: () => StyledText | string onSubmit: () => void onKeyDown: (event: KeyEvent) => void @@ -243,7 +251,7 @@ export function RunPromptBody(props: { return ( - +