diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 9859174a2..5b44517ec 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -23,7 +23,7 @@ runs: fi - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-download-url: ${{ steps.bun-url.outputs.url }} @@ -33,8 +33,9 @@ runs: shell: bash run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - - name: Cache Bun dependencies - uses: actions/cache@v4 + - name: Restore Bun dependencies + id: bun-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.cache.outputs.dir }} key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} @@ -56,3 +57,10 @@ runs: bun install ${{ inputs.install-flags }} fi shell: bash + + - name: Save Bun dependencies + if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ steps.cache.outputs.dir }} + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} diff --git a/.github/actions/setup-git-committer/action.yml b/.github/actions/setup-git-committer/action.yml index 87d2f5d0d..65c974c6a 100644 --- a/.github/actions/setup-git-committer/action.yml +++ b/.github/actions/setup-git-committer/action.yml @@ -19,7 +19,7 @@ runs: steps: - name: Create app token id: apptoken - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: app-id: ${{ inputs.opencode-app-id }} private-key: ${{ inputs.opencode-app-secret }} diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index a7106667b..e93d5fbdb 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml index 04b6ae7ac..b8a2e3f57 100644 --- a/.github/workflows/close-issues.yml +++ b/.github/workflows/close-issues.yml @@ -12,9 +12,9 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index e0e571b46..3a0fa4b5c 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 15 steps: - name: Close inactive PRs - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index c3bcf9f68..14e68701e 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close non-compliant issues and PRs after 2 hours - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const { data: items } = await github.rest.issues.listForRepo({ diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index c7df066d4..15bf07831 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -21,18 +21,18 @@ jobs: REGISTRY: ghcr.io/${{ github.repository_owner }} TAG: "24.04" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup-bun - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index abd8bafdd..7b4f53a98 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,11 +13,11 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ./.github/actions/setup-bun - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml index 9689eee6d..5f921e8bb 100644 --- a/.github/workflows/docs-locale-sync.yml +++ b/.github/workflows/docs-locale-sync.yml @@ -16,7 +16,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/docs-update.yml b/.github/workflows/docs-update.yml index 900ad2b0c..4767dec53 100644 --- a/.github/workflows/docs-update.yml +++ b/.github/workflows/docs-update.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 # Fetch full history to access commits @@ -43,7 +43,7 @@ jobs: - name: Run opencode if: steps.commits.outputs.has_commits == 'true' - uses: sst/opencode/github@latest + uses: sst/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 6c1943fe7..4648a2d0c 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -13,7 +13,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 @@ -125,7 +125,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 706ab2989..324cfec02 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/nix-eval.yml b/.github/workflows/nix-eval.yml index c76b2c972..75332695a 100644 --- a/.github/workflows/nix-eval.yml +++ b/.github/workflows/nix-eval.yml @@ -20,10 +20,10 @@ jobs: timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 + uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - name: Evaluate flake outputs (all systems) run: | diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 6b5b3929a..085f8895c 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -41,10 +41,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 + uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - name: Compute node_modules hash id: hash @@ -72,7 +72,7 @@ jobs: echo "Computed hash for ${SYSTEM}: $HASH" - name: Upload hash - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: hash-${{ matrix.system }} path: hash.txt @@ -85,7 +85,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false fetch-depth: 0 @@ -102,7 +102,7 @@ jobs: git pull --rebase --autostash origin "$GITHUB_REF_NAME" - name: Download hash artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: hashes pattern: hash-* diff --git a/.github/workflows/notify-discord.yml b/.github/workflows/notify-discord.yml index b1d805360..0b2b1cde0 100644 --- a/.github/workflows/notify-discord.yml +++ b/.github/workflows/notify-discord.yml @@ -9,6 +9,6 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Send nicely-formatted embed to Discord - uses: SethCohen/github-releases-to-discord@v1 + uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0 with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 76e75fcae..3469c2191 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -21,12 +21,12 @@ jobs: issues: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup-bun - name: Run opencode - uses: anomalyco/opencode/github@latest + uses: anomalyco/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_PERMISSION: '{"bash": "deny"}' diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml index 35bd7ae36..b6aa4e589 100644 --- a/.github/workflows/pr-management.yml +++ b/.github/workflows/pr-management.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 @@ -78,7 +78,7 @@ jobs: issues: write steps: - name: Add Contributor Label - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const isPR = !!context.payload.pull_request; diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 1edbd5d06..06838089d 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Check PR standards - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const pr = context.payload.pull_request; @@ -159,7 +159,7 @@ jobs: pull-requests: write steps: - name: Check PR template compliance - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const pr = context.payload.pull_request; diff --git a/.github/workflows/publish-github-action.yml b/.github/workflows/publish-github-action.yml index d2789373a..e5ca91b56 100644 --- a/.github/workflows/publish-github-action.yml +++ b/.github/workflows/publish-github-action.yml @@ -16,7 +16,7 @@ jobs: publish: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 diff --git a/.github/workflows/publish-vscode.yml b/.github/workflows/publish-vscode.yml index f49a10578..00c7e2604 100644 --- a/.github/workflows/publish-vscode.yml +++ b/.github/workflows/publish-vscode.yml @@ -15,7 +15,7 @@ jobs: publish: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5f7ee96b9..bef1e7029 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,7 +35,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'anomalyco/opencode' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 @@ -72,7 +72,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'anomalyco/opencode' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-tags: true @@ -95,14 +95,14 @@ jobs: GH_REPO: ${{ needs.version.outputs.repo }} GH_TOKEN: ${{ steps.committer.outputs.token }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-cli path: | packages/opencode/dist/opencode-darwin* packages/opencode/dist/opencode-linux* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-cli-windows path: packages/opencode/dist/opencode-windows* @@ -123,9 +123,9 @@ jobs: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli-windows path: packages/opencode/dist @@ -138,13 +138,13 @@ jobs: opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - name: Azure login - uses: azure/login@v2 + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - uses: azure/artifact-signing-action@v1 + - uses: azure/artifact-signing-action@b443cf8ea4124818d2ea9f043cba29fc3ec47b16 # v1.2.0 with: endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }} signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} @@ -201,7 +201,7 @@ jobs: --clobber ` --repo "${{ needs.version.outputs.repo }}" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-cli-signed-windows path: | @@ -249,9 +249,9 @@ jobs: platform_flag: --linux runs-on: ${{ matrix.settings.host }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: apple-actions/import-codesign-certs@v2 + - uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0 if: runner.os == 'macOS' with: keychain: build @@ -268,19 +268,19 @@ jobs: - name: Azure login if: runner.os == 'Windows' - uses: azure/login@v2 + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" - name: Cache apt packages if: contains(matrix.settings.host, 'ubuntu') - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/apt-cache key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }} @@ -388,12 +388,12 @@ jobs: } } - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-desktop-${{ matrix.settings.target }} path: packages/desktop/dist/* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: needs.version.outputs.release with: name: latest-yml-${{ matrix.settings.target }} @@ -408,44 +408,44 @@ jobs: if: always() && !failure() && !cancelled() runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ./.github/actions/setup-bun - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" registry-url: "https://registry.npmjs.org" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli path: packages/opencode/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli-windows path: packages/opencode/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli-signed-windows path: packages/opencode/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 if: needs.version.outputs.release with: pattern: latest-yml-* @@ -459,7 +459,7 @@ jobs: opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - name: Cache apt packages (AUR) - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: /var/cache/apt/archives key: ${{ runner.os }}-apt-aur-${{ hashFiles('.github/workflows/publish.yml') }} diff --git a/.github/workflows/release-github-action.yml b/.github/workflows/release-github-action.yml index 3f5caa55c..4a1d7218b 100644 --- a/.github/workflows/release-github-action.yml +++ b/.github/workflows/release-github-action.yml @@ -16,7 +16,7 @@ jobs: release: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 2bd1f0c4a..00a4fba8c 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -25,7 +25,7 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml index 824733901..bc97cfcd7 100644 --- a/.github/workflows/stats.yml +++ b/.github/workflows/stats.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 6d143a8a2..1e652104d 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -29,7 +29,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/sync-zed-extension.yml b/.github/workflows/sync-zed-extension.yml index f14487cde..6e4b44083 100644 --- a/.github/workflows/sync-zed-extension.yml +++ b/.github/workflows/sync-zed-extension.yml @@ -10,7 +10,7 @@ jobs: name: Release Zed Extension runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f226d3483..4a65b9927 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,12 +37,12 @@ jobs: shell: bash steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" @@ -55,7 +55,7 @@ jobs: git config --global user.name "opencode" - name: Cache Turbo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: node_modules/.cache/turbo key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }} @@ -75,7 +75,7 @@ jobs: - name: Publish unit reports if: always() - uses: mikepenz/action-junit-report@v6 + uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 with: report_paths: packages/*/.artifacts/unit/junit.xml check_name: "unit results (${{ matrix.settings.name }})" @@ -85,7 +85,7 @@ jobs: - name: Upload unit artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }} include-hidden-files: true @@ -111,12 +111,12 @@ jobs: shell: bash steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" @@ -131,7 +131,7 @@ jobs: - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ github.workspace }}/.playwright-browsers key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium @@ -155,7 +155,7 @@ jobs: - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }} if-no-files-found: ignore diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 99e7b5b34..27852a12c 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -12,7 +12,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index b247d24b4..fc9a52797 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -12,7 +12,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index dab531d33..0ae2fbe26 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,11 +1,7 @@ { "$schema": "https://opencode.ai/config.json", "provider": {}, - "permission": { - "edit": { - "packages/opencode/migration/*": "ask", - }, - }, + "permission": {}, "mcp": {}, "tools": { "github-triage": false, diff --git a/.opencode/skills/improve-codebase-architecture/DEEPENING.md b/.opencode/skills/improve-codebase-architecture/DEEPENING.md new file mode 100644 index 000000000..c52fdfd99 --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/DEEPENING.md @@ -0,0 +1,37 @@ +# Deepening + +How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**. + +## Dependency categories + +When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. + +### 1. In-process + +Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. + +### 2. Local-substitutable + +Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. + +### 3. Remote but owned (Ports & Adapters) + +Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. + +Recommendation shape: _"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."_ + +### 4. True external (Mock) + +Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. + +## Seam discipline + +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. +- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. + +## Testing strategy: replace, don't layer + +- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. +- Write new tests at the deepened module's interface. The **interface is the test surface**. +- Tests assert on observable outcomes through the interface, not internal state. +- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md new file mode 100644 index 000000000..3197723a0 --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md @@ -0,0 +1,44 @@ +# Interface Design + +When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. + +Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. + +## Process + +### 1. Frame the problem space + +Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: + +- The constraints any new interface would need to satisfy +- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) +- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete + +Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. + +### 2. Spawn sub-agents + +Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. + +Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: + +- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." +- Agent 2: "Maximise flexibility — support many use cases and extension." +- Agent 3: "Optimise for the most common caller — make the default case trivial." +- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." + +Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. + +Each sub-agent outputs: + +1. Interface (types, methods, params — plus invariants, ordering, error modes) +2. Usage example showing how callers use it +3. What the implementation hides behind the seam +4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) +5. Trade-offs — where leverage is high, where it's thin + +### 3. Present and compare + +Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. + +After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md new file mode 100644 index 000000000..dd9b60fea --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md @@ -0,0 +1,53 @@ +# Language + +Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. + +## Terms + +**Module** +Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice. +_Avoid_: unit, component, service. + +**Interface** +Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. +_Avoid_: API, signature (too narrow — those refer only to the type-level surface). + +**Implementation** +What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. + +**Depth** +Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. + +**Seam** _(from Michael Feathers)_ +A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. +_Avoid_: boundary (overloaded with DDD's bounded context). + +**Adapter** +A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside). + +**Leverage** +What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. + +**Locality** +What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere. + +## Principles + +- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. +- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. + +## Relationships + +- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). +- **Depth** is a property of a **Module**, measured against its **Interface**. +- A **Seam** is where a **Module**'s **Interface** lives. +- An **Adapter** sits at a **Seam** and satisfies the **Interface**. +- **Depth** produces **Leverage** for callers and **Locality** for maintainers. + +## Rejected framings + +- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. +- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. +- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. diff --git a/.opencode/skills/improve-codebase-architecture/SKILL.md b/.opencode/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 000000000..05984a609 --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +## Glossary + +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). + +- **Module** — anything with an interface and an implementation (function, class, package, slice). +- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. +- **Implementation** — the code inside. +- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation. +- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.") +- **Adapter** — a concrete thing satisfying an interface at a seam. +- **Leverage** — what callers get from depth. +- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. + +Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): + +- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** +- **One adapter = hypothetical seam. Two adapters = real seam.** + +This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. + +## Process + +### 1. Explore + +Read the project's domain glossary and any ADRs in the area you're touching first. + +Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates + +Present a numbered list of deepening opportunities. For each candidate: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve + +**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). +- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). diff --git a/bun.lock b/bun.lock index 41d1a5922..91e37b181 100644 --- a/bun.lock +++ b/bun.lock @@ -65,7 +65,6 @@ "solid-list": "catalog:", "tailwindcss": "catalog:", "virtua": "catalog:", - "zod": "catalog:", }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", @@ -152,12 +151,10 @@ "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", "@ai-sdk/openai-compatible": "2.0.37", - "@hono/zod-validator": "catalog:", "@openauthjs/openauth": "0.0.0-20250322224806", "@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-resource": "workspace:*", "ai": "catalog:", - "hono": "catalog:", "zod": "catalog:", }, "devDependencies": { @@ -216,7 +213,6 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "xdg-basedir": "5.1.0", - "zod": "catalog:", }, "devDependencies": { "@tsconfig/bun": "catalog:", @@ -401,6 +397,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -422,7 +419,6 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", - "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", @@ -512,9 +508,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.6", - "@opentui/keymap": ">=0.2.6", - "@opentui/solid": ">=0.2.6", + "@opentui/core": ">=0.2.8", + "@opentui/keymap": ">=0.2.8", + "@opentui/solid": ">=0.2.8", }, "optionalPeers": [ "@opentui/core", @@ -681,22 +677,25 @@ "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { + "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", + "@opentui/solid": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", - "@effect/opentelemetry": "4.0.0-beta.57", - "@effect/platform-node": "4.0.0-beta.57", + "@effect/opentelemetry": "4.0.0-beta.65", + "@effect/platform-node": "4.0.0-beta.65", "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@lydell/node-pty": "1.2.0-beta.10", "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.6", - "@opentui/keymap": "0.2.6", - "@opentui/solid": "0.2.6", + "@opentui/core": "0.2.8", + "@opentui/keymap": "0.2.8", + "@opentui/solid": "0.2.8", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -720,7 +719,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.59", + "effect": "4.0.0-beta.65", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -1075,17 +1074,15 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], - "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], - "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.57", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.57" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-gdjZPEP0QQg4qmI1vd+443kheeQZKytrjJIzCJncy6ZEpyk/SfrqeStLqLXdTRcms3IB0ls0vOV7KNq7YmBRVA=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.65", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.65" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-0CD2fSsXrDM7FP2WFkbGJO1DwMqWR3UKHh6oBDXPHAPA+RsJSKoh3pLQsbQfldLuKnhOy87Bv0v9r9IdrIHCQw=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.57", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.57", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.57", "ioredis": "^5.7.0" } }, "sha512-la0xxPSAYOsY0d+uVxEBxok3jYB31iPQmIaZZRUj2SNWqcGGHJc6KorKtI8guqSLuv9FGZ255kBWXRbG6hMeeg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.65", "ioredis": "^5.7.0" } }, "sha512-QQy3KRcMwP0TngQdfQGl2u1zp03B7k7DuF5SNS8aZhD0dDBpKZpCwFad1ODY5qdY3ycPgMwBwKRRK7y/aw0C9w=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.57", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.57" } }, "sha512-C976X6f+qHUtLSqcqImuCrjhAHnJV17NC2RvvybsAuDfkyIWU4MyiO2XwgiBeijeNupyr1M/KPKnyjtkNxV9Hw=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], @@ -1235,8 +1232,6 @@ "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], - "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="], - "@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="], "@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="], @@ -1295,62 +1290,6 @@ "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], - "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], - - "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], - - "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], - - "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], - - "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], - - "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], - - "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], - - "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], - - "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], - - "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], - - "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], - - "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], - - "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], - - "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], - - "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], - - "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], - - "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], - - "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], - - "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], - - "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], - - "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], - - "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], - - "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], - - "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], - - "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], - - "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], - - "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], - - "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], - "@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -1623,23 +1562,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.6", "", { "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.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="], + "@opentui/core": ["@opentui/core@0.2.8", "", { "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.2.8", "@opentui/core-darwin-x64": "0.2.8", "@opentui/core-linux-arm64": "0.2.8", "@opentui/core-linux-x64": "0.2.8", "@opentui/core-win32-arm64": "0.2.8", "@opentui/core-win32-x64": "0.2.8" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-bRRiCXuwjS8/6mN1oA5iVaf55z9APyalm7FnoxkLkEyIU1VDaQeTpYtElBbfo1rxtcO6Rj53XywH9oW8auNO9A=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Qh6VCMQgW3hWh/7MR51y+XuQezh8NOLwKS8EQSoKzAr4VOc/W5P0/DvgMKgwaqXw2Mz0AIba/BvZ6by20yc4zA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-wQjJ38C3IiVx/gwwBYxnCarzgD75FdS7IyUErt3lhn57XriNiCbb7ScphWnRMwwtL8CI+bBGzClroDRA2lCfvg=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-fx4ADeWSSSVU1O/MkMnklCRxtWRy6CLeAvktLlNdPb+BhmQIDg1kpZcdv7m/3cgD1/ksFEXIwO6VTvfKYE0umw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-4ekUyzopBj2ClsUbneLnUOrmZtvU67FCVFLgmBfKL4IvVl/P0YobGNg71gN1JNiYpY7hK77qOpidVLHcNMIE7w=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-63K046wpzTzQOLOG9LTsp3+Ld0TNTxeQczexkg0pKSBxZFhws+/9YIGjTctZmJUfE1g1X4tI31dO+KNRpXRHQw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.8", "", { "os": "win32", "cpu": "x64" }, "sha512-+WDiTlTyDpgkis8rPAhW1fS7TwXJih+fk+RYXS2bC3tAKsRD+O3PRSkVABRbjkuXbtfJZf2cjOHZFGN4Vf5qDg=="], - "@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="], + "@opentui/keymap": ["@opentui/keymap@0.2.8", "", { "dependencies": { "@opentui/core": "0.2.8" }, "peerDependencies": { "@opentui/react": "0.2.8", "@opentui/solid": "0.2.8", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/H9j8fP64cf3/nFDCvVP8+7cwU/oRh4sgfQH2NhcPp8illgBb/e9pG5x3vM0nK4RVyTqUvkPXsOeIX5u7vltlg=="], - "@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "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-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="], + "@opentui/solid": ["@opentui/solid@0.2.8", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.8", "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-f2g0riBuzk4/ZmcJnp1k13odUmNZcfA3nF7RzdSlEfpkwNDfc4xqnRAwYbNNDwGNrJX0JDCTEZY5ZEhuL155MQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2291,8 +2230,6 @@ "@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], - "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="], "@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="], @@ -2557,8 +2494,6 @@ "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], - "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], - "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], @@ -2627,8 +2562,6 @@ "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], - "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], - "aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="], "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], @@ -2693,8 +2626,6 @@ "blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="], - "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], - "body-parser": ["body-parser@1.20.4", "", { "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.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], @@ -2737,16 +2668,6 @@ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], - - "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], - - "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], - - "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="], - - "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], - "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -2817,8 +2738,6 @@ "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - "cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="], - "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=="], @@ -3041,7 +2960,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], + "effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -3169,8 +3088,6 @@ "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], - "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], - "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], @@ -3233,8 +3150,6 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], - "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -3243,8 +3158,6 @@ "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], - "find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="], - "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], @@ -3327,8 +3240,6 @@ "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="], - "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], - "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -3481,8 +3392,6 @@ "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], - "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], - "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], @@ -3625,16 +3534,12 @@ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], - "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="], "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], - "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], - "js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="], "js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="], @@ -4095,8 +4000,6 @@ "oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="], - "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], - "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -4171,12 +4074,6 @@ "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], - "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], - - "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], - - "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], - "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], @@ -4221,8 +4118,6 @@ "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="], - "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], @@ -4243,8 +4138,6 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], - "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], @@ -4253,16 +4146,12 @@ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], - "planck": ["planck@1.5.0", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-dlvqJE+FscZgrGUXJ5ybd0o5bvZ5XXyZNbm08xGsXp9WjXeAyWSFT6n9s/1PQcUBo4546fDXA5RMA4wbDyZw6g=="], - "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=="], "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "poe-oauth": ["poe-oauth@0.0.6", "", {}, "sha512-dI8xrVl7RSFh0B+cb4GGuCjIfGtDT9VpbpVkP0UKcunpXF0eFw+6GencoJ7k+E02ZYqopBQApMVWGq70/GP69w=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -4387,8 +4276,6 @@ "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], - "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], @@ -4573,8 +4460,6 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], "shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="], @@ -4597,8 +4482,6 @@ "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], - "simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="], - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sitemap": ["sitemap@9.0.1", "", { "dependencies": { "@types/node": "^24.9.2", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/esm/cli.js" } }, "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ=="], @@ -4685,8 +4568,6 @@ "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], - "stage-js": ["stage-js@1.0.2", "", {}, "sha512-EWTRBYlg7Qv9wGUao99/PfRe3KaiQqWmgSvTOXvaWnu1Jk/q/vV8yJVu6bi/3EqDZeMVnCPAjheba6OFc5k1GQ=="], - "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], @@ -4735,8 +4616,6 @@ "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], - "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], - "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], @@ -4789,8 +4668,6 @@ "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], - "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], - "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], @@ -4803,8 +4680,6 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], - "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], @@ -4825,8 +4700,6 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], - "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], "toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="], @@ -4979,8 +4852,6 @@ "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], - "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], - "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -5109,8 +4980,6 @@ "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], - "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], - "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], @@ -5463,48 +5332,10 @@ "@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], - "@happy-dom/global-registrator/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], - - "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@jsx-email/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jsx-email/cli/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], @@ -5643,24 +5474,16 @@ "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "@slack/logger/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], - "@slack/oauth/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], - "@slack/socket-mode/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@slack/socket-mode/@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], "@slack/socket-mode/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "@slack/web-api/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], - "@slack/web-api/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@slack/web-api/eventemitter3": ["eventemitter3@3.1.2", "", {}, "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="], "@slack/web-api/form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="], @@ -5721,62 +5544,8 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "@types/body-parser/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/cacache/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/cacheable-request/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/connect/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/cross-spawn/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/express-serve-static-core/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/fontkit/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/fs-extra/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/is-stream/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/jsonwebtoken/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/keyv/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/mssql/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/node-fetch/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/npm-registry-fetch/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/npmcli__arborist/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/npmlog/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/pacote/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/plist/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@types/readable-stream/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/responselike/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/sax/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/send/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/serve-static/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/ssri/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/tunnel/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/ws/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "@types/yauzl/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], @@ -5853,18 +5622,12 @@ "builder-util-runtime/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "bun-types/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - - "bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], - "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], - "cloudflare/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], @@ -5899,8 +5662,6 @@ "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "electron/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "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=="], @@ -5955,8 +5716,6 @@ "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - "happy-dom/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "happy-dom/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], @@ -5969,8 +5728,6 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], - "image-q/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "js-beautify/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=="], "js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="], @@ -6043,9 +5800,9 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - "opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="], + "opentui-spinner/@opentui/core": ["@opentui/core@0.2.7", "", { "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.2.7", "@opentui/core-darwin-x64": "0.2.7", "@opentui/core-linux-arm64": "0.2.7", "@opentui/core-linux-x64": "0.2.7", "@opentui/core-win32-arm64": "0.2.7", "@opentui/core-win32-x64": "0.2.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-cnN6JcaGC7SeQzobBy/CHzqUAQFtypazuw1CjQBo7WwoOiLMGubt9W5FXeF0zIrSxH2Ed6NLWhPYRg7SD4629Q=="], - "opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="], + "opentui-spinner/@opentui/solid": ["@opentui/solid@0.2.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.7", "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-nlkx9HvuWaHtc5A8eUEAPNi+5+37LZS3ln73WRmtT5xin8LnQf+yhwopqGgPSnLq1ODLwhkKRdr/9JCDr2j7Bg=="], "ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], @@ -6059,14 +5816,10 @@ "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - "parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], - "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], @@ -6091,8 +5844,6 @@ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "protobufjs/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -6123,8 +5874,6 @@ "shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], - "sitemap/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "sitemap/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -6147,20 +5896,14 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "stripe/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "tedious/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - "tree-sitter-bash/node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], "tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -6177,8 +5920,6 @@ "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "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=="], @@ -6493,8 +6234,6 @@ "@gitlab/opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], "@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], @@ -6689,14 +6428,6 @@ "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "@slack/logger/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@slack/oauth/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@slack/socket-mode/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@slack/web-api/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6723,60 +6454,6 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@types/body-parser/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/cacache/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/cacheable-request/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/connect/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/cross-spawn/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/express-serve-static-core/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/fontkit/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/fs-extra/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/is-stream/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/keyv/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/mssql/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/node-fetch/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/npm-registry-fetch/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/npmcli__arborist/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/npmlog/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/pacote/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/plist/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/readable-stream/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/responselike/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/sax/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/send/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/serve-static/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/ssri/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/tunnel/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/ws/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "@types/yauzl/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -6825,12 +6502,8 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "bun-types/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "cloudflare/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "crc/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -6849,8 +6522,6 @@ "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - "electron/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -6863,14 +6534,10 @@ "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "happy-dom/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], "iconv-corefoundation/cli-truncate/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=="], - "image-q/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "js-beautify/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -6889,8 +6556,6 @@ "motion/framer-motion/motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], - "mssql/tedious/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], - "mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -6909,24 +6574,22 @@ "opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="], + "opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CAy6cL3byz2Xf6gFiJHBpcnsp/2ADEWLLOUokVypOyPLcy8GY3sPzlA4pkAjVGQMYQhDj+Y3+SXz4uTLt4AETg=="], - "opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="], + "opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-K06h333rMkC9cyMJr/VvcRK3ik81Admd8ZsES5uf5YXWPdYhXGf75I1T8mKIThhUmoFLb8R5xqfuPmoocsjM7Q=="], - "opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="], + "opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-iYWGTztbdG9yYSB5Alxuo0dWAmkWQR0+/paNWUyPOocjigmKgMmACDtHgYqa7sxkIcWgmXljt/f8rgXDG4wdMg=="], - "opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="], + "opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-tymBCfYbsDRfHQNXsolkFfaTEIDhemD4+1ZovUztQd7i+0Ggnu9WbPN1SNCiRz6PjrlaNeQzZE3Wl8FfVdw/cw=="], - "opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="], + "opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-XLPJWdT8QOukrYDkpIng6+uNUlF66ByXcQlC3qA9JbrUTBetZhgXs8Q2jEjRfc+Ty3uh1iRSA6PgJGbbOK/f4Q=="], - "opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="], + "opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-CzVGEfqysVk8Hxcj0RDv/DtXIM6iZmbmr23kW7y8CJMPtmV1gmKI4D9abVjynWJnGbaSBnDi43mgZnGMgOdyEg=="], - "opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "opentui-spinner/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], - "opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], - "ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "ora/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=="], @@ -6935,14 +6598,10 @@ "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], - "protobufjs/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], @@ -6953,16 +6612,10 @@ "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "sitemap/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "stripe/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - - "tedious/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "tw-to-css/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "tw-to-css/tailwindcss/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -7265,8 +6918,6 @@ "js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "mssql/tedious/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], - "opencontrol/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "opencontrol/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], diff --git a/bunfig.toml b/bunfig.toml index 36a21d933..47c4ac539 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,6 +1,8 @@ [install] exact = true +# Only install newly resolved package versions published at least 3 days ago. +minimumReleaseAge = 259200 +minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid"] [test] root = "./do-not-run-tests-from-root" - diff --git a/infra/console.ts b/infra/console.ts index ab6502a8f..c247dfcdb 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -293,3 +293,12 @@ new sst.cloudflare.x.SolidStart("Console", { }, }, }) + +//////////////// +// HELPERS +//////////////// + +export const stat = new sst.cloudflare.Worker("Stat", { + handler: "packages/console/function/src/stat.ts", + link: [database], +}) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index b4ece54be..c08d39f26 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -1,6 +1,8 @@ import { SECRET } from "./secret" import { domain } from "./stage" +const description = "Managed by SST (Don't edit in Honeycomb UI)" + const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, url: `https://${domain}/honeycomb/webhook`, @@ -25,6 +27,16 @@ const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { ], }) +// Honeycomb can keep stale query-local calculated fields when the name is unchanged, +// so tie the field name to the expression while avoiding deploy-to-deploy churn. +// https://github.com/honeycombio/terraform-provider-honeycombio/issues/852 +const calculatedField = (field: { name: string; expression: string }) => ({ + ...field, + name: `${field.name}_${( + Array.from(field.expression).reduce((result, char) => Math.imul(31, result) + char.charCodeAt(0), 0) >>> 0 + ).toString(36)}`, +}) + const modelHttpErrorsQuery = (product: "go" | "zen") => { const filters = [ { column: "model", op: "exists" }, @@ -32,21 +44,26 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { { column: "user_agent", op: "contains", value: "opencode" }, { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, ] + const failedHttpStatus = calculatedField({ + name: "is_failed_http_status", + expression: + product === "go" + ? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)` + : `IF(AND(EQUALS($status, "429"), $isFreeTier), 0, AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, + }) return honeycomb.getQuerySpecificationOutput({ breakdowns: ["model"], - calculatedFields: [ - { - name: "is_failed_http_status", - expression: - product === "go" - ? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)` - : `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, - }, - ], + calculatedFields: [failedHttpStatus], calculations: [ { op: "COUNT", name: "TOTAL", filterCombination: "AND", filters }, - { op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters }, + { + op: "SUM", + name: "FAILED", + column: failedHttpStatus.name, + filterCombination: "AND", + filters, + }, ], formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }], timeRange: 900, @@ -59,31 +76,30 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { { column: "user_agent", op: "contains", value: "opencode" }, { column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" }, ] + const successHttpStatus = calculatedField({ + name: "is_success_http_status", + expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`, + }) + const failedProviderHttpStatus = calculatedField({ + name: "is_failed_provider_http_status", + expression: `IF(GT($llm.error.code, "400"), 1, 0)`, + }) return honeycomb.getQuerySpecificationOutput({ breakdowns: ["provider"], - calculatedFields: [ - { - name: "is_success_http_status", - expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`, - }, - { - name: "is_failed_provider_http_status", - expression: `IF(GT($llm.error.code, "400"), 1, 0)`, - }, - ], + calculatedFields: [successHttpStatus, failedProviderHttpStatus], calculations: [ { op: "SUM", name: "SUCCESS", - column: "is_success_http_status", + column: successHttpStatus.name, filterCombination: "AND", filters: [...filters, { column: "event_type", op: "=", value: "completions" }], }, { op: "SUM", name: "FAILED", - column: "is_failed_provider_http_status", + column: failedProviderHttpStatus.name, filterCombination: "AND", filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }], }, @@ -95,8 +111,6 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }).json } -const description = "Managed by SST (Don't edit in Honeycomb UI)" - new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { name: "Increased Model HTTP Errors [Go]", description, diff --git a/nix/hashes.json b/nix/hashes.json index 4244e0c0e..70372b820 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=", - "aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=", - "aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=", - "x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU=" + "x86_64-linux": "sha256-CF+ecq3PqUuA7ta09oYkDaFz+FxZqQragu9xvtw++RE=", + "aarch64-linux": "sha256-QVdgdCncl+XEIx569oNNRXyxRh5mkEMJSTSO1I3QfzM=", + "aarch64-darwin": "sha256-dfP1SEpgQ63DEfG4eRD7BWGoMPFmjGSWiW4M0J4Asfg=", + "x86_64-darwin": "sha256-JgXg+BwZTWroChnxE6akoEFfdb7vVH0qBMZSZ7TJhDU=" } } diff --git a/package.json b/package.json index 3fa966f68..be22f6045 100644 --- a/package.json +++ b/package.json @@ -28,16 +28,16 @@ "packages/slack" ], "catalog": { - "@effect/opentelemetry": "4.0.0-beta.57", - "@effect/platform-node": "4.0.0-beta.57", + "@effect/opentelemetry": "4.0.0-beta.65", + "@effect/platform-node": "4.0.0-beta.65", "@npmcli/arborist": "9.4.0", "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.6", - "@opentui/keymap": "0.2.6", - "@opentui/solid": "0.2.6", + "@opentui/core": "0.2.8", + "@opentui/keymap": "0.2.8", + "@opentui/solid": "0.2.8", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -55,7 +55,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.59", + "effect": "4.0.0-beta.65", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", @@ -128,6 +128,9 @@ "electron" ], "overrides": { + "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", + "@opentui/solid": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:" }, diff --git a/packages/app/package.json b/packages/app/package.json index 9eb408372..86999ed45 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -73,7 +73,6 @@ "solid-js": "catalog:", "solid-list": "catalog:", "tailwindcss": "catalog:", - "virtua": "catalog:", - "zod": "catalog:" + "virtua": "catalog:" } } diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 63a321e46..ac3bc03e4 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -107,7 +107,8 @@ function createCommandEntries(props: { const allowed = createMemo(() => { if (props.filesOnly()) return [] return props.command.options.filter( - (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", + (option) => + !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", ) }) diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 576ec8fec..cc841e278 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -6,7 +6,8 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" import { useLanguage } from "@/context/language" -import { mcpQueryKey } from "@/context/global-sync" +import { useQueryOptions } from "@/context/global-sync" +import { pathKey } from "@/utils/path-key" const statusLabels = { connected: "mcp.status.connected", @@ -20,6 +21,7 @@ export const DialogSelectMcp: Component = () => { const sdk = useSDK() const language = useLanguage() const queryClient = useQueryClient() + const queryOptions = useQueryOptions() const items = createMemo(() => Object.entries(sync.data.mcp ?? {}) @@ -32,7 +34,7 @@ export const DialogSelectMcp: Component = () => { if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name }) else await sdk.client.mcp.connect({ name }) }, - onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }), + onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))), })) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2417fa98e..eaeedf087 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -16,7 +16,6 @@ import { } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useGlobalSDK } from "@/context/global-sdk" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" @@ -56,7 +55,8 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { useQueries } from "@tanstack/solid-query" -import { loadAgentsQuery, loadProvidersQuery } from "@/context/global-sync/bootstrap" +import { useQueryOptions } from "@/context/global-sync" +import { pathKey } from "@/utils/path-key" interface PromptInputProps { class?: string @@ -103,7 +103,7 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/ export const PromptInput: Component = (props) => { const sdk = useSDK() - const globalSDK = useGlobalSDK() + const queryOptions = useQueryOptions() const sync = useSync() const local = useLocal() @@ -1256,9 +1256,9 @@ export const PromptInput: Component = (props) => { const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ queries: [ - loadAgentsQuery(sdk.directory, sdk.client), - loadProvidersQuery(null, globalSDK.client), - loadProvidersQuery(sdk.directory, sdk.client), + queryOptions.agents(pathKey(sdk.directory)), + queryOptions.providers(null), + queryOptions.providers(pathKey(sdk.directory)), ], })) diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 7d2dfaa63..149a0309b 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -123,11 +123,13 @@ function listFor(command: CommandContext, map: KeybindMap, palette: string) { for (const opt of command.catalog) { if (opt.id.startsWith("suggested.")) continue + if (opt.hidden) continue out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) } for (const opt of command.options) { if (opt.id.startsWith("suggested.")) continue + if (opt.hidden) continue out.set(opt.id, { title: opt.title, group: groupFor(opt.id) }) } diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index bbac56278..405c7538c 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -15,7 +15,8 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -import { mcpQueryKey } from "@/context/global-sync" +import { useQueryOptions } from "@/context/global-sync" +import { pathKey } from "@/utils/path-key" const pollMs = 10_000 @@ -139,13 +140,14 @@ const useMcpToggleMutation = () => { const sdk = useSDK() const language = useLanguage() const queryClient = useQueryClient() + const queryOptions = useQueryOptions() return useMutation(() => ({ mutationFn: async (name: string) => { const status = sync.data.mcp[name] await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) }, - onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }), + onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))), onError: (err) => { showToast({ variant: "error", diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index d2238828c..e979ad6a0 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -81,6 +81,7 @@ export interface CommandOption { slash?: string suggested?: boolean disabled?: boolean + hidden?: boolean onSelect?: (source?: "palette" | "keybind" | "slash") => void onHighlight?: () => (() => void) | void } @@ -93,6 +94,7 @@ export type CommandCatalogItem = { category?: string keybind?: KeybindConfig slash?: string + hidden?: boolean } export type CommandRegistration = { @@ -279,13 +281,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex setCatalog( registered().reduce((acc, opt) => { const id = actionId(opt.id) - acc[id] = { - title: opt.title, - description: opt.description, - category: opt.category, - keybind: opt.keybind, - slash: opt.slash, - } + if (opt.title) + acc[id] = { + title: opt.title, + description: opt.description, + category: opt.category, + keybind: opt.keybind, + slash: opt.slash, + } return acc }, {} as CommandCatalog), ) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index e53d60d5a..001b90b42 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -3,15 +3,13 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" import { batch, onCleanup, onMount } from "solid-js" -import z from "zod" import { createSdkForServer } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { useServer } from "./server" -const abortError = z.object({ - name: z.literal("AbortError"), -}) +const isAbortError = (error: unknown) => + error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ name: "GlobalSDK", @@ -103,7 +101,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo let streamErrorLogged = false const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - const aborted = (error: unknown) => abortError.safeParse(error).success + const aborted = isAbortError let attempt: AbortController | undefined let run: Promise | undefined diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 31c90463d..594f94fb6 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -18,8 +18,10 @@ import { bootstrapDirectory, bootstrapGlobal, clearProviderRev, + loadAgentsQuery, loadGlobalConfigQuery, loadPathQuery, + loadProjectsQuery, loadProvidersQuery, } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" @@ -33,6 +35,7 @@ import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" +import { PathKey } from "@/utils/path-key" type GlobalStore = { ready: boolean @@ -48,24 +51,33 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -export const loadSessionsQueryKey = (directory: string) => [directory, "loadSessions"] as const - -export const mcpQueryKey = (directory: string) => [directory, "mcp"] as const - export const loadMcpQuery = (directory: string, sdk: OpencodeClient) => queryOptions({ - queryKey: mcpQueryKey(directory), + queryKey: [directory, "mcp"] as const, queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), }) -export const lspQueryKey = (directory: string) => [directory, "lsp"] as const - export const loadLspQuery = (directory: string, sdk: OpencodeClient) => queryOptions({ - queryKey: lspQueryKey(directory), + queryKey: [directory, "lsp"] as const, queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), }) +function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) { + return { + globalConfig: () => loadGlobalConfigQuery(globalSDK()), + projects: () => loadProjectsQuery(globalSDK()), + providers: (directory: PathKey | null) => + loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)), + path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)), + agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)), + mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)), + lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)), + sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }), + } +} +export type QueryOptionsApi = ReturnType + function createGlobalSync() { const globalSDK = useGlobalSDK() const language = useLanguage() @@ -77,12 +89,22 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() + const sdkFor = (directory: string) => { + const key = directoryKey(directory) + const cached = sdkCache.get(key) + if (cached) return cached + const sdk = globalSDK.createClient({ + directory, + throwOnError: true, + }) + sdkCache.set(key, sdk) + return sdk + } + + const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor) + const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ - queries: [ - loadGlobalConfigQuery(globalSDK.client), - loadProvidersQuery(null, globalSDK.client), - loadPathQuery(null, globalSDK.client), - ], + queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], })) const [globalStore, setGlobalStore] = createStore({ @@ -181,18 +203,6 @@ function createGlobalSync() { bootstrapInstance, }) - const sdkFor = (directory: string) => { - const key = directoryKey(directory) - const cached = sdkCache.get(key) - if (cached) return cached - const sdk = globalSDK.createClient({ - directory, - throwOnError: true, - }) - sdkCache.set(key, sdk) - return sdk - } - const children = createChildStoreManager({ owner, isBooting: (directory) => booting.has(directory), @@ -209,7 +219,7 @@ function createGlobalSync() { clearSessionPrefetchDirectory(key) }, translate: language.t, - getSdk: sdkFor, + queryOptions: queryOptionsApi, global: { provider: globalStore.provider, }, @@ -239,7 +249,7 @@ function createGlobalSync() { const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const promise = queryClient .fetchQuery({ - queryKey: loadSessionsQueryKey(key), + ...queryOptionsApi.sessions(key), queryFn: () => loadRootSessionsWithFallback({ directory, @@ -368,7 +378,7 @@ function createGlobalSync() { setSessionTodo, vcsCache: children.vcsCache.get(key), loadLsp: () => { - void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory))) + void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, }) }) @@ -426,6 +436,7 @@ function createGlobalSync() { }, child: children.child, peek: children.peek, + queryOptions: queryOptionsApi, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, @@ -447,3 +458,7 @@ export function useGlobalSync() { if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider") return context } + +export function useQueryOptions() { + return useGlobalSync().queryOptions +} diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 30dda8691..bb8eb7ce7 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -22,7 +22,7 @@ describe("createChildStoreManager", () => { onBootstrap() {}, onDispose() {}, translate: (key) => key, - getSdk: () => null!, + queryOptions: {} as any, global: { provider: null! }, }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 0138310cd..e8ca597d1 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -15,8 +15,7 @@ import { } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" import { useQueries } from "@tanstack/solid-query" -import { loadPathQuery, loadProvidersQuery } from "./bootstrap" -import { loadLspQuery, loadMcpQuery } from "../global-sync" +import { QueryOptionsApi } from "../global-sync" import { directoryKey, type DirectoryKey } from "./utils" export function createChildStoreManager(input: { @@ -26,7 +25,7 @@ export function createChildStoreManager(input: { onBootstrap: (directory: string) => void onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string - getSdk: (directory: string) => OpencodeClient + queryOptions: QueryOptionsApi global: { provider: ProviderListResponse } @@ -171,17 +170,15 @@ export function createChildStoreManager(input: { const init = () => createRoot((dispose) => { - const sdk = input.getSdk(directory) - const initialMeta = meta[0].value const initialIcon = icon[0].value const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ queries: [ - loadPathQuery(key, sdk), - loadMcpQuery(key, sdk), - loadLspQuery(key, sdk), - loadProvidersQuery(key, sdk), + input.queryOptions.path(key), + input.queryOptions.mcp(key), + input.queryOptions.lsp(key), + input.queryOptions.providers(key), ], })) @@ -231,6 +228,7 @@ export function createChildStoreManager(input: { limit: 5, message: {}, part: {}, + part_text_accum_delta: {}, }) children[key] = child disposers.set(key, dispose) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 892129788..f02ac5c7b 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -81,6 +81,7 @@ const baseState = (input: Partial = {}) => limit: 10, message: {}, part: {}, + part_text_accum_delta: {}, ...input, }) as State diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5f43c341b..13d34ef6c 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: { const result = Binary.search(messages, props.messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) } + const parts = draft.part[props.messageID] + if (parts) { + for (const part of parts) { + delete draft.part_text_accum_delta[part.id] + } + } delete draft.part[props.messageID] }), ) @@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: { case "message.part.updated": { const part = (event.properties as { part: Part }).part if (SKIP_PARTS.has(part.type)) break + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[part.id] + }), + ) const parts = input.store.part[part.messageID] if (!parts) { input.setStore("part", part.messageID, [part]) @@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: { } case "message.part.removed": { const props = event.properties as { messageID: string; partID: string } + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[props.partID] + }), + ) const parts = input.store.part[props.messageID] if (!parts) break const result = Binary.search(parts, props.partID, (p) => p.id) @@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: { if (!parts) break const result = Binary.search(parts, props.partID, (p) => p.id) if (!result.found) break + input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta) input.setStore( "part", props.messageID, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 472ac219e..4b2be505e 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -39,6 +39,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + part_text_accum_delta: Record } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, session_diff: { ses_1: [] }, @@ -47,12 +48,14 @@ describe("app session cache", () => { part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, + part_text_accum_delta: { prt_1: "streamed text" }, } dropSessionCaches(store, ["ses_1"]) expect(store.message.ses_1).toBeUndefined() expect(store.part.msg_1).toBeUndefined() + expect(store.part_text_accum_delta.prt_1).toBeUndefined() expect(store.todo.ses_1).toBeUndefined() expect(store.session_diff.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined() @@ -70,6 +73,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + part_text_accum_delta: Record } = { session_status: {}, session_diff: {}, @@ -78,6 +82,7 @@ describe("app session cache", () => { part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, + part_text_accum_delta: {}, } dropSessionCaches(store, ["ses_1"]) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 6f4d81062..05cdc8464 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -18,6 +18,7 @@ type SessionCache = { part: Record permission: Record question: Record + part_text_accum_delta: Record } export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { @@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable stale.has(part?.sessionID ?? ""))) continue + for (const part of parts) { + delete store.part_text_accum_delta[part.id] + } delete store.part[key] } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index e3ec83c5e..6bf42a073 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -72,6 +72,9 @@ export type State = { part: { [messageID: string]: Part[] } + part_text_accum_delta: { + [partID: string]: string + } } export type VcsCache = { diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index cacc875c5..0d37dd26a 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -43,6 +43,7 @@ type SessionView = { reviewOpen?: string[] pendingMessage?: string pendingMessageAt?: number + todoCollapsed?: boolean } type TabHandoff = { @@ -759,6 +760,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setScroll(tab: string, pos: SessionScroll) { scroll.setScroll(key(), tab, pos) }, + todoCollapsed: { + get: () => s().todoCollapsed ?? false, + set(collapsed: boolean) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed }) + } else { + setStore("sessionView", session, "todoCollapsed", collapsed) + } + }, + }, terminal: { opened: terminalOpened, open() { diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index f467e9034..4465a0261 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -44,7 +44,7 @@ const migrate = (value: unknown) => { } const clone = (value: State | undefined) => { - if (!value) return undefined + if (!value) return return { ...value, model: value.model ? { ...value.model } : undefined, @@ -104,7 +104,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const pickAgent = (name: string | undefined) => { const items = list() - if (items.length === 0) return undefined + if (items.length === 0) return return items.find((item) => item.name === name) ?? items[0] } @@ -227,14 +227,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ () => agent.current()?.model, fallback, ) - if (!item) return undefined + if (!item) return return models.find(item) } const configured = () => { const item = agent.current() const model = current() - if (!item || !model) return undefined + if (!item || !model) return return getConfiguredAgentVariant({ agent: { model: item.model, variant: item.variant }, model: { providerID: model.provider.id, modelID: model.id, variants: model.variants }, @@ -314,11 +314,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ configured, selected, current() { - return resolveModelVariant({ + const resolved = resolveModelVariant({ variants: this.list(), selected: this.selected(), configured: this.configured(), }) + if (resolved) return resolved + const model = current() + if (!model) return + const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id }) + if (saved && this.list().includes(saved)) return saved }, list() { const item = current() @@ -335,6 +340,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ variant: value ?? null, }) write({ variant: value ?? null }) + if (model) { + models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined) + } }) }, cycle() { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 250d26edb..a42bb6261 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -25,6 +25,7 @@ export const dict = { "command.project.open": "Open project", "command.project.previous": "Previous project", "command.project.next": "Next project", + "command.project.index": "Switch to project {{index}}", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a08372649..31d3e5dcc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -960,6 +960,15 @@ export default function Layout(props: ParentProps) { void openProject(target.worktree) } + function navigateToProjectIndex(index: number) { + const projects = layout.projects.list() + const target = projects[index] + if (!target) return + + globalSync.child(target.worktree) + void openProject(target.worktree) + } + function navigateSessionByUnseen(offset: number) { const sessions = currentSessions() if (sessions.length === 0) return @@ -1040,6 +1049,19 @@ export default function Layout(props: ParentProps) { keybind: "mod+alt+arrowdown", onSelect: () => navigateProjectByOffset(1), }, + ...Array.from({ length: 9 }, (_, i) => { + const index = i + const number = index + 1 + return { + id: `project.${number}`, + category: language.t("command.category.project"), + title: `Open Project {number}`, + keybind: `mod+${number}`, + disabled: layout.projects.list().length <= index, + hidden: true, + onSelect: () => navigateToProjectIndex(index), + } + }), { id: "provider.connect", title: language.t("command.provider.connect"), @@ -1409,19 +1431,20 @@ export default function Layout(props: ParentProps) { const index = list.findIndex((x) => pathKey(x.worktree) === key) const active = pathKey(currentProject()?.worktree ?? "") === key if (index === -1) return - const next = list[index + 1] if (!active) { layout.projects.close(directory) return } - if (!next) { + if (list.length === 1) { layout.projects.close(directory) navigate("/") return } + const next = list[index + 1] ?? list[index - 1] + navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`) layout.projects.close(directory) queueMicrotask(() => { @@ -1934,7 +1957,7 @@ export default function Layout(props: ParentProps) { if (!created?.directory) return - setWorkspaceName(created.directory, created.branch, project.id, created.branch) + setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch) const local = project.worktree const key = pathKey(created.directory) @@ -2096,6 +2119,7 @@ export default function Layout(props: ParentProps) { } + keyed > {(project) => ( <> @@ -2106,9 +2130,7 @@ export default function Layout(props: ParentProps) { id={`project:${projectId()}`} value={projectName} onSave={(next) => { - const item = project() - if (!item) return - void renameProject(item, next) + void renameProject(project, next) }} class="text-14-medium text-text-strong truncate" displayClass="text-14-medium text-text-strong truncate" @@ -2150,9 +2172,7 @@ export default function Layout(props: ParentProps) { { - const item = project() - if (!item) return - showEditProjectDialog(item) + showEditProjectDialog(project) }} > {language.t("common.edit")} @@ -2162,9 +2182,7 @@ export default function Layout(props: ParentProps) { data-project={slug()} disabled={!canToggle()} onSelect={() => { - const item = project() - if (!item) return - toggleProjectWorkspaces(item) + toggleProjectWorkspaces(project) }} > @@ -2223,7 +2241,7 @@ export default function Layout(props: ParentProps) {
@@ -2238,9 +2256,7 @@ export default function Layout(props: ParentProps) { icon="plus-small" class="w-full" onClick={() => { - const item = project() - if (!item) return - void createWorkspace(item) + void createWorkspace(project) }} > {language.t("workspace.new")} @@ -2267,7 +2283,7 @@ export default function Layout(props: ParentProps) { diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index f27a9bb7a..3aac5a613 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -166,18 +166,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { }) const isWorking = createMemo(() => { if (hasPermissions()) return false - const pending = (sessionStore.message[props.session.id] ?? []).findLast( - (message) => - message.role === "assistant" && - typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number", - ) + // This matches how the TUI does it const status = sessionStore.session_status[props.session.id] - return ( - pending !== undefined || - status?.type === "busy" || - status?.type === "retry" || - (status !== undefined && status.type !== "idle") - ) + return status?.type === "busy" || status?.type === "retry" }) const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 9b80adac2..f423c13d1 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -14,7 +14,7 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" import { type Session } from "@opencode-ai/sdk/v2/client" import { type LocalProject } from "@/context/layout" -import { loadSessionsQueryKey, useGlobalSync } from "@/context/global-sync" +import { useGlobalSync, useQueryOptions } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { pathKey } from "@/utils/path-key" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" @@ -300,6 +300,7 @@ export const SortableWorkspace = (props: { const navigate = useNavigate() const params = useParams() const globalSync = useGlobalSync() + const queryOptions = useQueryOptions() const language = useLanguage() const sortable = createSortable(props.directory) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) @@ -320,7 +321,7 @@ export const SortableWorkspace = (props: { const boot = createMemo(() => open() || active()) const count = createMemo(() => sessions()?.length ?? 0) const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) - const fetching = useIsFetching(() => ({ queryKey: loadSessionsQueryKey(props.directory) })) + const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.directory))) const busy = createMemo(() => props.ctx.isBusy(props.directory)) const loading = () => fetching() > 0 && count() === 0 const touch = createMediaQuery("(hover: none)") @@ -446,6 +447,7 @@ export const LocalWorkspace = (props: { mobile?: boolean }): JSX.Element => { const globalSync = useGlobalSync() + const queryOptions = useQueryOptions() const language = useLanguage() const workspace = createMemo(() => { const [store, setStore] = globalSync.child(props.project.worktree) @@ -454,7 +456,7 @@ export const LocalWorkspace = (props: { const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const count = createMemo(() => sessions()?.length ?? 0) - const fetching = useIsFetching(() => ({ queryKey: loadSessionsQueryKey(props.project.worktree) })) + const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.project.worktree))) const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const loading = () => fetching() > 0 && count() === 0 const loadMore = async () => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e916cc2f0..08fad258f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1359,10 +1359,8 @@ export default function Page() { }) const busy = (sessionID: string) => { - if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true - return (sync.data.message[sessionID] ?? []).some( - (item) => item.role === "assistant" && typeof item.time.completed !== "number", - ) + // This matches how the TUI does it + return (sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle" } const queuedFollowups = createMemo(() => { diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 60447566e..e6bfd05ec 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -2,6 +2,7 @@ import { Show, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" +import { useLayout } from "@/context/layout" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -46,10 +47,12 @@ export function SessionComposerRegion(props: { setPromptDockRef: (el: HTMLDivElement) => void }) { const navigate = useNavigate() + const layout = useLayout() const prompt = usePrompt() const language = useLanguage() const route = useSessionKey() const sync = useSync() + const view = layout.view(route.sessionKey) const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt) const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined)) @@ -207,6 +210,8 @@ export function SessionComposerRegion(props: { view.todoCollapsed.set(!view.todoCollapsed.get())} collapseLabel={language.t("session.todo.collapse")} expandLabel={language.t("session.todo.expand")} dockProgress={value()} diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index fa8c17734..fccbeec17 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -42,18 +42,17 @@ function dot(status: Todo["status"]) { export function SessionTodoDock(props: { sessionID?: string todos: Todo[] + collapsed: boolean + onToggle: () => void collapseLabel: string expandLabel: string dockProgress: number }) { const language = useLanguage() const [store, setStore] = createStore({ - collapsed: false, height: 320, }) - const toggle = () => setStore("collapsed", (value) => !value) - const total = createMemo(() => props.todos.length) const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() })) @@ -72,7 +71,7 @@ export function SessionTodoDock(props: { ) const preview = createMemo(() => active()?.content ?? "") - const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) + const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress))) const shut = createMemo(() => 1 - dock()) const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) @@ -107,11 +106,11 @@ export function SessionTodoDock(props: { class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible" role="button" tabIndex={0} - onClick={toggle} + onClick={props.onToggle} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return event.preventDefault() - toggle() + props.onToggle() }} > { event.stopPropagation() - toggle() + props.onToggle() }} - aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} + aria-label={props.collapsed ? props.expandLabel : props.collapseLabel} />
0.1, }} diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 5719fce31..92288c63b 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -32,7 +32,7 @@ export interface SessionReviewTabProps { focusedComment?: { file: string; id: string } | null onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void focusedFile?: string - onScrollRef?: (el: HTMLDivElement) => void + onScrollRef?: (el: HTMLDivElement | undefined) => void commentMentions?: { items: (query: string) => string[] | Promise } @@ -126,6 +126,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) { onCleanup(() => { if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) + props.onScrollRef?.(undefined) }) return ( diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 66f5269bf..9a745891a 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -221,239 +221,241 @@ export function SessionSidePanel(props: { }} style={{ width: panelWidth() }} > -
-
-
- - - - -
- { - const stop = createFileTabListSync({ el, contextOpen }) - onCleanup(stop) - }} - > - - -
-
{language.t("session.tab.review")}
- -
{props.reviewCount()}
-
-
-
-
- - - tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
-
-
-
- - {(tab) => } - -
- - { - void import("@/components/dialog-select-file").then((x) => { - dialog.show(() => ) - }) - }} - aria-label={language.t("command.file.open")} - /> - -
-
-
- - - - {props.reviewPanel()} - - - - - -
-
- -
- {language.t("session.files.selectToOpen")} -
-
-
-
-
- - - - -
- -
-
-
-
- - - {(tab) => } - -
- - - {(tab) => { - const path = file.pathFromTab(tab) - return ( -
- {(p) => } -
- ) - }} -
-
-
-
-
- - + +
-
- + - - - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} - - - {language.t("session.files.all")} - - - - - - - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} + + + +
+ { + const stop = createFileTabListSync({ el, contextOpen }) + onCleanup(stop) + }} + > + + +
+
{language.t("session.tab.review")}
+ +
{props.reviewCount()}
+
- } - > +
+
+ + + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
+ + {(tab) => } + +
+ + { + void import("@/components/dialog-select-file").then((x) => { + dialog.show(() => ) + }) + }} + aria-label={language.t("command.file.open")} + /> + +
+
+
+ + + + {props.reviewPanel()} + + + + + +
+
+ +
+ {language.t("session.files.selectToOpen")} +
+
+
+
+
+ + + + +
+ +
+
+
+
+ + + {(tab) => } + +
+ + + {(tab) => { + const path = file.pathFromTab(tab) + return ( +
+ {(p) => } +
+ ) + }} +
+
+
+
+
+ + +
+
+ + + + {props.reviewCount()}{" "} + {language.t( + props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", + )} + + + {language.t("session.files.all")} + + + + + + + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } + > + props.focusReviewDiff(node.path)} + /> + + + + + + + {empty(language.t("session.files.empty"))} + props.focusReviewDiff(node.path)} + onFileClick={(node) => openTab(file.tab(node.path))} /> - - - - - - - {empty(language.t("session.files.empty"))} - - openTab(file.tab(node.path))} - /> - - - - -
- -
props.size.start()}> - { - props.size.touch() - layout.fileTree.resize(width) - }} - /> + + + +
-
-
-
-
+ +
props.size.start()}> + { + props.size.touch() + layout.fileTree.resize(width) + }} + /> +
+
+
+ + + ) diff --git a/packages/app/src/utils/id.ts b/packages/app/src/utils/id.ts index fa27cf4c5..dba7a8d95 100644 --- a/packages/app/src/utils/id.ts +++ b/packages/app/src/utils/id.ts @@ -1,5 +1,3 @@ -import z from "zod" - const prefixes = { session: "ses", message: "msg", @@ -15,10 +13,6 @@ let counter = 0 type Prefix = keyof typeof prefixes export namespace Identifier { - export function schema(prefix: Prefix) { - return z.string().startsWith(prefixes[prefix]) - } - export function ascending(prefix: Prefix, given?: string) { return generateID(prefix, false, given) } diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index dad65807d..540dfe7e8 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -47,6 +47,7 @@ import { Resource } from "@opencode-ai/console-resource" import { i18n, type Key } from "~/i18n" import { localeFromRequest } from "~/lib/language" import { createModelTpmLimiter } from "./modelTpmLimiter" +import { createModelTpsLimiter } from "./modelTpsLimiter" type ZenData = Awaited> type RetryOptions = { @@ -129,6 +130,8 @@ export async function handler( logger.metric({ source: billingSource }) const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers) const modelTpmLimits = await modelTpmLimiter?.check() + const modelTpsLimiter = createModelTpsLimiter(modelInfo.providers) + const modelTpsLimits = await modelTpsLimiter?.check() const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { const providerInfo = selectProvider( @@ -142,6 +145,7 @@ export async function handler( retry, stickyProvider, modelTpmLimits, + modelTpsLimits, ) validateModelSettings(billingSource, authInfo) updateProviderKey(authInfo, providerInfo) @@ -294,14 +298,16 @@ export async function handler( let buffer = "" let responseLength = 0 + let timestampFirstByte = 0 function pump(): Promise { return ( reader?.read().then(async ({ done, value: rawValue }) => { if (done) { + const timestampLastByte = Date.now() logger.metric({ response_length: responseLength, - "timestamp.last_byte": Date.now(), + "timestamp.last_byte": timestampLastByte, }) dataDumper?.flush() await rateLimiter?.track() @@ -311,6 +317,13 @@ export async function handler( const costInfo = calculateCost(modelInfo, usageInfo) await trialLimiter?.track(usageInfo) await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo) + await modelTpsLimiter?.track( + providerInfo.id, + providerInfo.model, + timestampFirstByte, + timestampLastByte, + usageInfo, + ) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await reload(billingSource, authInfo, costInfo) const cost = calculateOccurredCost(billingSource, costInfo) @@ -321,10 +334,10 @@ export async function handler( } if (responseLength === 0) { - const now = Date.now() + timestampFirstByte = Date.now() logger.metric({ - time_to_first_byte: now - startTimestamp, - "timestamp.first_byte": now, + time_to_first_byte: timestampFirstByte - startTimestamp, + "timestamp.first_byte": timestampFirstByte, }) } @@ -478,6 +491,7 @@ export async function handler( retry: RetryOptions, stickyProvider: string | undefined, modelTpmLimits: Record | undefined, + modelTpsLimits: Record | undefined, ) { const modelProvider = (() => { // Byok is top priority b/c if user set their own API key, we should use it @@ -509,6 +523,11 @@ export async function handler( const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0 return usage < provider.tpmLimit * 1_000_000 }) + .filter((provider) => { + if (!provider.tpsGoal) return true + const isLowTps = modelTpsLimits?.[`${provider.id}/${provider.model}`] ?? false + return !isLowTps + }) .map((provider) => { topPriority = Math.min(topPriority, provider.priority) return provider diff --git a/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts new file mode 100644 index 000000000..428272eec --- /dev/null +++ b/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts @@ -0,0 +1,89 @@ +import { and, Database, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js" +import { ModelTpsRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" +import { UsageInfo } from "./provider/provider" + +export function createModelTpsLimiter(providers: { id: string; model: string; tpsGoal?: number }[]) { + const tpsGoals = Object.fromEntries( + providers.flatMap((p) => { + return p.tpsGoal ? [[`${p.id}/${p.model}`, p.tpsGoal]] : [] + }), + ) + const ids = Object.keys(tpsGoals) + if (ids.length === 0) return + + const toInterval = (date: Date) => + parseInt( + date + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 12), + ) + const now = Date.now() + const currInterval = toInterval(new Date(now)) + const prevInterval = toInterval(new Date(now - 60 * 1000)) + + return { + check: async () => { + const data = await Database.use((tx) => + tx + .select() + .from(ModelTpsRateLimitTable) + .where( + and( + inArray(ModelTpsRateLimitTable.id, ids), + inArray(ModelTpsRateLimitTable.interval, [currInterval, prevInterval]), + ), + ), + ) + + // convert to map of model to summed count across current and previous intervals + const result = data.reduce( + (acc, curr) => { + const existing = acc[curr.id] ?? { qualify: 0, unqualify: 0 } + acc[curr.id] = { + qualify: existing.qualify + curr.qualify, + unqualify: existing.unqualify + curr.unqualify, + } + return acc + }, + {} as Record, + ) + + return Object.fromEntries( + Object.entries(result).map(([id, { qualify, unqualify }]) => { + const isLowTps = qualify + unqualify > 10 && qualify < unqualify + return [id, isLowTps] + }), + ) + }, + track: async (provider: string, model: string, tsFirstByte: number, tsLastByte: number, usageInfo: UsageInfo) => { + const id = `${provider}/${model}` + if (!ids.includes(id)) return + const tpsGoal = tpsGoals[id] + if (!tpsGoal) return + if (tsFirstByte <= 0 || tsLastByte <= 0) return + const tokens = usageInfo.outputTokens + if (tokens <= 10) return + + const tps = (tokens / (tsLastByte - tsFirstByte)) * 1000 + const qualify = tps >= tpsGoal ? 1 : 0 + const unqualify = tps < tpsGoal ? 1 : 0 + await Database.use((tx) => + tx + .insert(ModelTpsRateLimitTable) + .values({ + id, + interval: currInterval, + qualify, + unqualify, + }) + .onDuplicateKeyUpdate({ + set: { + qualify: sql`${ModelTpsRateLimitTable.qualify} + ${qualify}`, + unqualify: sql`${ModelTpsRateLimitTable.unqualify} + ${unqualify}`, + }, + }), + ) + }, + } +} diff --git a/packages/console/core/migrations/20260511220522_fine_shaman/migration.sql b/packages/console/core/migrations/20260511220522_fine_shaman/migration.sql new file mode 100644 index 000000000..3af8255a1 --- /dev/null +++ b/packages/console/core/migrations/20260511220522_fine_shaman/migration.sql @@ -0,0 +1,7 @@ +CREATE TABLE `model_tps_rate_limit` ( + `id` varchar(255) NOT NULL, + `interval` bigint NOT NULL, + `qualify` int NOT NULL, + `unqualify` int NOT NULL, + CONSTRAINT PRIMARY KEY(`id`,`interval`) +); diff --git a/packages/console/core/migrations/20260511220522_fine_shaman/snapshot.json b/packages/console/core/migrations/20260511220522_fine_shaman/snapshot.json new file mode 100644 index 000000000..b175f6d4b --- /dev/null +++ b/packages/console/core/migrations/20260511220522_fine_shaman/snapshot.json @@ -0,0 +1,2685 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "c742e0f2-5d89-4216-b843-059d00680f13", + "prevIds": ["b3b243c0-8097-4d8a-a439-243d5a7d543f"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["email", "type"], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": ["id", "interval"], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index dc3febe05..b0851c49f 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -36,6 +36,7 @@ export namespace ZenData { model: z.string(), priority: z.number().optional(), tpmLimit: z.number().optional(), + tpsGoal: z.number().optional(), weight: z.number().optional(), disabled: z.boolean().optional(), storeModel: z.string().optional(), diff --git a/packages/console/core/src/schema/ip.sql.ts b/packages/console/core/src/schema/ip.sql.ts index 94087abe5..975dcfa18 100644 --- a/packages/console/core/src/schema/ip.sql.ts +++ b/packages/console/core/src/schema/ip.sql.ts @@ -40,3 +40,14 @@ export const ModelTpmRateLimitTable = mysqlTable( }, (table) => [primaryKey({ columns: [table.id, table.interval] })], ) + +export const ModelTpsRateLimitTable = mysqlTable( + "model_tps_rate_limit", + { + id: varchar("id", { length: 255 }).notNull(), + interval: bigint("interval", { mode: "number" }).notNull(), + qualify: int("qualify").notNull(), + unqualify: int("unqualify").notNull(), + }, + (table) => [primaryKey({ columns: [table.id, table.interval] })], +) diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 9680a53aa..088db5be2 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -298,6 +298,7 @@ declare module "sst" { "EnterpriseStorage": cloudflare.R2Bucket "GatewayKv": cloudflare.KVNamespace "LogProcessor": cloudflare.Service + "Stat": cloudflare.Service "ZenData": cloudflare.R2Bucket "ZenDataNew": cloudflare.R2Bucket } diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 41487f845..5c1d1ba22 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -20,12 +20,10 @@ "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", "@ai-sdk/openai-compatible": "2.0.37", - "@hono/zod-validator": "catalog:", "@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-resource": "workspace:*", "@openauthjs/openauth": "0.0.0-20250322224806", "ai": "catalog:", - "hono": "catalog:", "zod": "catalog:" } } diff --git a/packages/console/function/src/stat.ts b/packages/console/function/src/stat.ts new file mode 100644 index 000000000..078aabb51 --- /dev/null +++ b/packages/console/function/src/stat.ts @@ -0,0 +1,39 @@ +import { WorkerEntrypoint } from "cloudflare:workers" +import { and, Database, inArray } from "@opencode-ai/console-core/drizzle/index.js" +import { ModelTpsRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" + +type Result = Record + +export default class Stat extends WorkerEntrypoint { + async fetch() { + return new Response("Not Found", { status: 404 }) + } + + async getStats(ids: string[]): Promise { + if (ids.length === 0) return {} + + const toInterval = (date: Date) => + parseInt( + date + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 12), + ) + const now = Date.now() + const intervals = Array.from({ length: 5 }, (_, i) => toInterval(new Date(now - i * 60 * 1000))) + + const rows = await Database.use((tx) => + tx + .select() + .from(ModelTpsRateLimitTable) + .where(and(inArray(ModelTpsRateLimitTable.id, ids), inArray(ModelTpsRateLimitTable.interval, intervals))), + ) + + const result: Result = Object.fromEntries(ids.map((id) => [id, { qualify: 0, unqualify: 0 }])) + for (const row of rows) { + result[row.id].qualify += row.qualify + result[row.id].unqualify += row.unqualify + } + return result + } +} diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 9680a53aa..088db5be2 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -298,6 +298,7 @@ declare module "sst" { "EnterpriseStorage": cloudflare.R2Bucket "GatewayKv": cloudflare.KVNamespace "LogProcessor": cloudflare.Service + "Stat": cloudflare.Service "ZenData": cloudflare.R2Bucket "ZenDataNew": cloudflare.R2Bucket } diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 9680a53aa..088db5be2 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -298,6 +298,7 @@ declare module "sst" { "EnterpriseStorage": cloudflare.R2Bucket "GatewayKv": cloudflare.KVNamespace "LogProcessor": cloudflare.Service + "Stat": cloudflare.Service "ZenData": cloudflare.R2Bucket "ZenDataNew": cloudflare.R2Bucket } diff --git a/packages/core/package.json b/packages/core/package.json index e2ffa31d8..6bcef68dc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,8 +41,7 @@ "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "semver": "^7.6.3", - "xdg-basedir": "5.1.0", - "zod": "catalog:" + "xdg-basedir": "5.1.0" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/src/effect-zod.ts b/packages/core/src/effect-zod.ts deleted file mode 100644 index 42d89ec7d..000000000 --- a/packages/core/src/effect-zod.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { Effect, Option, Schema, SchemaAST } from "effect" -import z from "zod" - -/** - * Annotation key for providing a hand-crafted Zod schema that the walker - * should use instead of re-deriving from the AST. Attach it via - * `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`. - */ -export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") - -// AST nodes are immutable and frequently shared across schemas (e.g. a single -// Schema.Class embedded in multiple parents). Memoizing by node identity -// avoids rebuilding equivalent Zod subtrees and keeps derived children stable -// by reference across callers. -const walkCache = new WeakMap() - -// Shared empty ParseOptions for the rare callers that need one — avoids -// allocating a fresh object per parse inside refinements and transforms. -const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions - -export function zod(schema: S): z.ZodType> { - return walk(schema.ast) as z.ZodType> -} - -/** - * Derive a Zod value from an Effect Schema (or a Schema-backed export with a - * `.zod` static) and narrow the result to `z.ZodObject` so `.shape`, - * `.omit`, `.extend`, and friends are accessible. - * - * The `zod()` walker returns `z.ZodType` because not every AST node decodes - * to an object; this helper keeps the "I started from a `Schema.Struct`" cast - * in one place instead of sprinkling `as unknown as z.ZodObject` across - * call sites. - * - * The return is intentionally loose — carrying Schema field types through the - * mapped `.omit()` / `.extend()` surface triggers brand-intersection - * explosions for branded primitives (`string & Brand<"SessionID">` extends - * `object` via the brand and gets walked into the prototype by `DeepPartial`, - * mapped-schema helpers, and zod's inference through `z.ZodType` - * wrappers also can't reconstruct `T` cleanly. Consumers that care about the - * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type. - */ -export function zodObject(schema: S): z.ZodObject { - const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast) - return derived as unknown as z.ZodObject -} - -function isZodType(value: unknown): value is z.ZodTypeAny { - return typeof value === "object" && value !== null && "_zod" in value -} - -/** - * Emit a JSON Schema for a tool/route parameter schema — derives the zod form - * via the walker so Effect Schema inputs flow through the same zod-openapi - * pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what - * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper. - */ -export function toJsonSchema(schema: S) { - return z.toJSONSchema(zod(schema), { io: "input" }) -} - -function walk(ast: SchemaAST.AST): z.ZodTypeAny { - const cached = walkCache.get(ast) - if (cached) return cached - const result = walkUncached(ast) - walkCache.set(ast, result) - return result -} - -function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { - const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined - // `description` annotations layer on top of an override so callers can - // reuse a shared override schema (e.g. `SessionID`) and still add a - // per-field description on the outer wrapper. - const base = override ?? bodyWithChecks(ast) - const desc = SchemaAST.resolveDescription(ast) - const ref = SchemaAST.resolveIdentifier(ast) - const described = desc ? base.describe(desc) : base - return ref ? described.meta({ ref }) : described -} - -function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { - // Schema.Class wraps its fields in a Declaration AST plus an encoding that - // constructs the class instance. For the Zod derivation we want the plain - // field shape (the decoded/consumer view), not the class instance — so - // Declarations fall through to body(), not encoded(). User-level - // Schema.decodeTo / Schema.transform attach encoding to non-Declaration - // nodes, where we do apply the transform. - // - // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` - // on the inner Zod rather than a transform wrapper — so optional ASTs whose - // encoding resolves a default from Option.none() route through body()/opt(). - const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) - const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) - const base = hasTransform ? encoded(ast) : body(ast) - return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base -} - -// Walk the encoded side and apply each link's decode to produce the decoded -// shape. A node `Target` produced by `from.decodeTo(Target)` carries -// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls -// nest the encoding via `Link.to` so walking it recursively threads all -// prior transforms — typical encoding.length is 1. -function encoded(ast: SchemaAST.AST): z.ZodTypeAny { - const encoding = ast.encoding! - return encoding.reduce( - (acc, link) => acc.transform((v) => decode(link.transformation, v)), - walk(encoding[0].to), - ) -} - -// Transformations built via pure `SchemaGetter.transform(fn)` (the common -// decodeTo case) resolve synchronously, so running with no services is safe. -// Effectful / middleware-based transforms will surface as Effect defects. -function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown { - const exit = Effect.runSyncExit( - (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect< - Option.Option - >, - ) - if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`) - return Option.getOrElse(exit.value, () => value) -} - -// Flatten FilterGroups and any nested variants into a linear list of Filters. -// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are -// translated into native Zod methods so their JSON Schema output includes -// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …). -// Anything else falls back to a single .superRefine layer — runtime-only, -// emits no JSON Schema constraint. -function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny { - const filters: SchemaAST.Filter[] = [] - const collect = (c: SchemaAST.Check) => { - if (c._tag === "FilterGroup") c.checks.forEach(collect) - else filters.push(c) - } - checks.forEach(collect) - - const unhandled: SchemaAST.Filter[] = [] - const translated = filters.reduce((acc, filter) => { - const next = translateFilter(acc, filter) - if (next) return next - unhandled.push(filter) - return acc - }, out) - - if (unhandled.length === 0) return translated - - return translated.superRefine((value, ctx) => { - for (const filter of unhandled) { - const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS) - if (!issue) continue - const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed" - ctx.addIssue({ code: "custom", message }) - } - }) -} - -// Translate a well-known Effect Schema filter into a native Zod method call on -// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every -// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at -// construction time. Returns `undefined` for unrecognised filters so the -// caller can fall back to the generic .superRefine path. -function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter): z.ZodTypeAny | undefined { - const meta = (filter.annotations as { meta?: Record } | undefined)?.meta - if (!meta || typeof meta._tag !== "string") return undefined - switch (meta._tag) { - case "isInt": - return call(out, "int") - case "isFinite": - return call(out, "finite") - case "isGreaterThan": - return call(out, "gt", meta.exclusiveMinimum) - case "isGreaterThanOrEqualTo": - return call(out, "gte", meta.minimum) - case "isLessThan": - return call(out, "lt", meta.exclusiveMaximum) - case "isLessThanOrEqualTo": - return call(out, "lte", meta.maximum) - case "isBetween": { - const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum) - if (!lo) return undefined - return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum) - } - case "isMultipleOf": - return call(out, "multipleOf", meta.divisor) - case "isMinLength": - return call(out, "min", meta.minLength) - case "isMaxLength": - return call(out, "max", meta.maxLength) - case "isLengthBetween": { - const lo = call(out, "min", meta.minimum) - if (!lo) return undefined - return call(lo, "max", meta.maximum) - } - case "isPattern": - return call(out, "regex", meta.regExp) - case "isStartsWith": - return call(out, "startsWith", meta.startsWith) - case "isEndsWith": - return call(out, "endsWith", meta.endsWith) - case "isIncludes": - return call(out, "includes", meta.includes) - case "isUUID": - return call(out, "uuid") - case "isULID": - return call(out, "ulid") - case "isBase64": - return call(out, "base64") - case "isBase64Url": - return call(out, "base64url") - } - return undefined -} - -// Invoke a named Zod method on `target` if it exists, otherwise return -// undefined so the caller can fall back. Using this helper instead of a -// typed cast keeps `translateFilter` free of per-case narrowing noise. -function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined { - const fn = (target as unknown as Record z.ZodTypeAny) | undefined>)[method] - return typeof fn === "function" ? fn.apply(target, args) : undefined -} - -function issueMessage(issue: any): string | undefined { - if (typeof issue?.annotations?.message === "string") return issue.annotations.message - if (typeof issue?.message === "string") return issue.message - return undefined -} - -function body(ast: SchemaAST.AST): z.ZodTypeAny { - if (SchemaAST.isOptional(ast)) return opt(ast) - - switch (ast._tag) { - case "String": - return z.string() - case "Number": - return z.number() - case "Boolean": - return z.boolean() - case "Null": - return z.null() - case "Undefined": - return z.undefined() - case "Any": - case "Unknown": - return z.unknown() - case "Never": - return z.never() - case "Literal": - return z.literal(ast.literal) - case "Union": - return union(ast) - case "Objects": - return object(ast) - case "Arrays": - return array(ast) - case "Declaration": - return decl(ast) - default: - return fail(ast) - } -} - -function opt(ast: SchemaAST.AST): z.ZodTypeAny { - if (ast._tag !== "Union") return fail(ast) - const items = ast.types.filter((item) => item._tag !== "Undefined") - const inner = - items.length === 1 - ? walk(items[0]) - : items.length > 1 - ? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) - : z.undefined() - // Schema.withDecodingDefault attaches an encoding `Link` whose transformation - // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke - // it to extract the default and emit `.default(...)` instead of `.optional()`. - const fallback = extractDefault(ast) - if (fallback !== undefined) return inner.default(fallback.value) - return inner.optional() -} - -type DecodeLink = { - readonly transformation: { - readonly decode: { - readonly run: ( - input: Option.Option, - options: SchemaAST.ParseOptions, - ) => Effect.Effect, unknown> - } - } -} - -function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined { - const encoding = (ast as { encoding?: ReadonlyArray }).encoding - if (!encoding?.length) return undefined - // Walk the chain of encoding Links in order; the first Getter that produces - // a value from Option.none wins. withDecodingDefault always puts its - // defaulting Link adjacent to the optional Union. - for (const link of encoding) { - const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {})) - if (probe._tag !== "Success") continue - if (Option.isSome(probe.value)) return { value: probe.value.value } - } - return undefined -} - -function union(ast: SchemaAST.Union): z.ZodTypeAny { - // When every member is a string literal, emit z.enum() so that - // JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }. - if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) { - return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]]) - } - - const items = ast.types.map(walk) - if (items.length === 1) return items[0] - if (items.length < 2) return fail(ast) - - const discriminator = ast.annotations?.discriminator - if (typeof discriminator === "string") { - return z.discriminatedUnion(discriminator, items as [z.ZodObject, z.ZodObject, ...z.ZodObject[]]) - } - - return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) -} - -function object(ast: SchemaAST.Objects): z.ZodTypeAny { - // Pure record: { [k: string]: V } - if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) { - const sig = ast.indexSignatures[0] - if (sig.parameter._tag !== "String") return fail(ast) - return z.record(z.string(), walk(sig.type)) - } - - // Pure object with known fields and no index signatures. - if (ast.indexSignatures.length === 0) { - return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) - } - - // Struct with a catchall (StructWithRest): known fields + index signature. - // Only supports a single string-keyed index signature; multi-signature or - // symbol/number keys fall through to fail. - if (ast.indexSignatures.length !== 1) return fail(ast) - const sig = ast.indexSignatures[0] - if (sig.parameter._tag !== "String") return fail(ast) - return z - .object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)]))) - .catchall(walk(sig.type)) -} - -function array(ast: SchemaAST.Arrays): z.ZodTypeAny { - // Pure variadic arrays: { elements: [], rest: [item] } - if (ast.elements.length === 0) { - if (ast.rest.length !== 1) return fail(ast) - return z.array(walk(ast.rest[0])) - } - // Fixed-length tuples: { elements: [a, b, ...], rest: [] } - // Tuples with a variadic tail (...rest) are not yet supported. - if (ast.rest.length > 0) return fail(ast) - const items = ast.elements.map(walk) - return z.tuple(items as [z.ZodTypeAny, ...Array]) -} - -function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { - if (ast.typeParameters.length !== 1) return fail(ast) - return walk(ast.typeParameters[0]) -} - -function fail(ast: SchemaAST.AST): never { - const ref = SchemaAST.resolveIdentifier(ast) - throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`) -} diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 3fe765575..f76d1aaf9 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,5 +1,4 @@ import { Config } from "effect" -import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -11,13 +10,6 @@ function falsy(key: string) { return value === "false" || value === "0" } -// Channels where new experiments default to ON (unstable / internal users). -// Stable channels (`prod`, `latest`) stay opt-in. -const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"]) -function unstableDefault(key: string) { - return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel)) -} - function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -56,9 +48,6 @@ export const Flag = { OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), - // Default-on for dev/beta/local; opt-in for stable. Set - // OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on. - OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"), OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], @@ -96,6 +85,7 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), + OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"), // 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/process.ts b/packages/core/src/process.ts new file mode 100644 index 000000000..2da8eb834 --- /dev/null +++ b/packages/core/src/process.ts @@ -0,0 +1,207 @@ +import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect" +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" + +export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { + command: Schema.String, + exitCode: Schema.optional(Schema.Number), + stderr: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), +}) {} + +export interface RunOptions { + readonly maxOutputBytes?: number + readonly maxErrorBytes?: number + readonly signal?: AbortSignal + readonly timeout?: Duration.Input +} + +export interface RunStreamOptions { + readonly signal?: AbortSignal + readonly includeStderr?: boolean + readonly okExitCodes?: ReadonlyArray + readonly maxErrorBytes?: number +} + +export interface RunResult { + readonly command: string + readonly exitCode: number + readonly stdout: Buffer + readonly stderr: Buffer + readonly truncated: boolean +} + +export type Interface = ChildProcessSpawner["Service"] & { + readonly run: (command: ChildProcess.Command, options?: RunOptions) => Effect.Effect + readonly runStream: ( + command: ChildProcess.Command, + options?: RunStreamOptions, + ) => Stream.Stream +} + +export class Service extends Context.Service()("@opencode/AppProcess") {} + +export const requireSuccess = (result: RunResult): Effect.Effect => + result.exitCode === 0 + ? Effect.succeed(result) + : Effect.fail( + new AppProcessError({ + command: result.command, + exitCode: result.exitCode, + stderr: result.stderr.toString("utf8"), + }), + ) + +export const requireExitIn = + (codes: ReadonlyArray) => + (result: RunResult): Effect.Effect => + codes.includes(result.exitCode) + ? Effect.succeed(result) + : Effect.fail( + new AppProcessError({ + command: result.command, + exitCode: result.exitCode, + stderr: result.stderr.toString("utf8"), + }), + ) + +const describeCommand = (command: ChildProcess.Command): string => { + if (command._tag === "StandardCommand") { + return command.args.length ? `${command.command} ${command.args.join(" ")}` : command.command + } + return `${describeCommand(command.left)} | ${describeCommand(command.right)}` +} + +const wrapError = (description: string, cause: unknown): AppProcessError => + cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause }) + +const abortError = (signal: AbortSignal): Error => { + const reason = signal.reason + if (reason instanceof Error) return reason + const err = new Error("Aborted") + err.name = "AbortError" + return err +} + +const waitForAbort = (signal: AbortSignal) => + Effect.callback((resume) => { + if (signal.aborted) { + resume(Effect.fail(abortError(signal))) + return + } + const onabort = () => resume(Effect.fail(abortError(signal))) + signal.addEventListener("abort", onabort, { once: true }) + return Effect.sync(() => signal.removeEventListener("abort", onabort)) + }) + +const collectStream = (stream: Stream.Stream, maxOutputBytes: number | undefined) => + Stream.runFold( + stream, + () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }), + (acc, chunk) => { + if (maxOutputBytes === undefined) { + acc.chunks.push(chunk) + acc.bytes += chunk.length + return acc + } + const remaining = maxOutputBytes - acc.bytes + if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining)) + acc.bytes += chunk.length + acc.truncated = acc.truncated || acc.bytes > maxOutputBytes + return acc + }, + ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + + const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) { + const description = describeCommand(command) + const collect = Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn(command) + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStream(handle.stdout, options?.maxOutputBytes), + collectStream(handle.stderr, options?.maxErrorBytes), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ) + return { + command: description, + exitCode, + stdout: stdout.buffer, + stderr: stderr.buffer, + truncated: stdout.truncated, + } satisfies RunResult + }), + ) + const timed = options?.timeout + ? Effect.timeoutOrElse(collect, { + duration: options.timeout, + orElse: () => Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })), + }) + : collect + const aborted = options?.signal + ? timed.pipe( + Effect.raceFirst( + waitForAbort(options.signal).pipe(Effect.mapError((cause) => wrapError(description, cause))), + ), + ) + : timed + return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause)))) + }) + + const runStream = ( + command: ChildProcess.Command, + options?: RunStreamOptions, + ): Stream.Stream => { + const description = describeCommand(command) + const okExitCodes = options?.okExitCodes + const built: Stream.Stream = Stream.unwrap( + Effect.gen(function* () { + const handle = yield* spawner.spawn(command) + const stderrFiber = yield* Effect.forkScoped( + collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))), + ) + const source = options?.includeStderr === true ? handle.all : handle.stdout + const lines = source.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.filter((line) => line.length > 0), + ) + const tail = Stream.unwrap( + Effect.gen(function* () { + const code = yield* handle.exitCode + if (okExitCodes && okExitCodes.length > 0 && !okExitCodes.includes(code)) { + const stderr = yield* Fiber.join(stderrFiber) + return Stream.fail(new AppProcessError({ command: description, exitCode: code, stderr })) + } + return Stream.empty + }), + ) + return Stream.concat(lines, tail) as Stream.Stream + }), + ) + const mapped = built.pipe( + Stream.catch((cause): Stream.Stream => Stream.fail(wrapError(description, cause))), + ) + if (!options?.signal) return mapped + const signal = options.signal + return mapped.pipe( + Stream.interruptWhen(waitForAbort(signal).pipe(Effect.mapError((cause) => wrapError(description, cause)))), + ) + } + + return Service.of({ ...spawner, run, runStream }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) + +export * as AppProcess from "./process" diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 2a6c02349..5b4042c73 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,5 +1,4 @@ import { Option, Schema, SchemaGetter } from "effect" -import { zod, ZodOverride } from "./effect-zod" /** * Integer greater than zero. @@ -21,7 +20,6 @@ export const optionalOmitUndefined = (schema: S) => decode: SchemaGetter.passthrough({ strict: false }), encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), }), - Schema.annotate({ [ZodOverride]: zod(schema).optional() }), ) /** diff --git a/packages/core/src/util/error.ts b/packages/core/src/util/error.ts index 9d3b7c661..7338571f2 100644 --- a/packages/core/src/util/error.ts +++ b/packages/core/src/util/error.ts @@ -1,8 +1,8 @@ -import z from "zod" +import { Schema } from "effect" export abstract class NamedError extends Error { - abstract schema(): z.core.$ZodType - abstract toObject(): { name: string; data: any } + abstract schema(): Schema.Top + abstract toObject(): { name: string; data: unknown } static hasName(error: unknown, name: string): boolean { return ( @@ -10,30 +10,42 @@ export abstract class NamedError extends Error { ) } - static create(name: Name, data: Data) { - const schema = z - .object({ - name: z.literal(name), - data, - }) - .meta({ - ref: name, - }) + static create( + name: Name, + fields: Fields, + ): ReturnType>> + static create( + name: Name, + data: DataSchema, + ): ReturnType> + static create(name: Name, data: Schema.Top | Schema.Struct.Fields) { + return NamedError.createSchemaClass(name, Schema.isSchema(data) ? data : Schema.Struct(data)) + } + + private static createSchemaClass(name: Name, data: DataSchema) { + const schema = Schema.Struct({ + name: Schema.Literal(name), + data, + }).annotate({ identifier: name }) + type Data = Schema.Schema.Type + const result = class extends NamedError { public static readonly Schema = schema + public static readonly EffectSchema = schema + public static readonly tag = name - public override readonly name = name as Name + public override readonly name = name constructor( - public readonly data: z.input, + public readonly data: Data, options?: ErrorOptions, ) { super(name, options) this.name = name } - static isInstance(input: any): input is InstanceType { - return typeof input === "object" && "name" in input && input.name === name + static isInstance(input: unknown): input is InstanceType { + return NamedError.hasName(input, name) } schema() { @@ -51,10 +63,7 @@ export abstract class NamedError extends Error { return result } - public static readonly Unknown = NamedError.create( - "UnknownError", - z.object({ - message: z.string(), - }), - ) + public static readonly Unknown = NamedError.create("UnknownError", { + message: Schema.String, + }) } diff --git a/packages/core/src/util/fn.ts b/packages/core/src/util/fn.ts deleted file mode 100644 index 9efe4622f..000000000 --- a/packages/core/src/util/fn.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from "zod" - -export function fn(schema: T, cb: (input: z.infer) => Result) { - const result = (input: z.infer) => { - const parsed = schema.parse(input) - return cb(parsed) - } - result.force = (input: z.infer) => cb(input) - result.schema = schema - return result -} diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index e1962aed4..83060b29c 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -4,11 +4,14 @@ import path from "path" import fs from "fs/promises" import { createWriteStream } from "fs" import * as Global from "../global" -import z from "zod" +import { Schema } from "effect" import { Glob } from "./glob" -export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) -export type Level = z.infer +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, diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts new file mode 100644 index 000000000..726c3c4d8 --- /dev/null +++ b/packages/core/test/process/process.test.ts @@ -0,0 +1,206 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Stream } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { AppProcess } from "@opencode-ai/core/process" +import { testEffect } from "../lib/effect" + +const it = testEffect(AppProcess.defaultLayer) + +const NODE = process.execPath +const cmd = (...args: string[]) => ChildProcess.make(NODE, args) + +describe("AppProcess", () => { + describe("run", () => { + it.effect( + "captures stdout and exit code zero", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')")) + expect(result.exitCode).toBe(0) + expect(result.stdout.toString("utf8")).toBe("hi\n") + expect(result.truncated).toBe(false) + }), + ) + + it.effect( + "non-zero exit returns RunResult; caller can require success", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.exit(1)")) + expect(result.exitCode).toBe(1) + }), + ) + + it.effect( + "requireSuccess fails on non-zero exit", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const exit = yield* Effect.exit( + svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)), + ) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const reason = exit.cause.reasons[0] + if (reason && reason._tag === "Fail") { + expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError) + expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1) + } else { + throw new Error("expected fail reason") + } + } + }), + ) + + it.effect( + "requireSuccess succeeds on exit 0", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess)) + expect(result.exitCode).toBe(0) + }), + ) + + it.effect( + "requireExitIn allowlists multiple exit codes", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const requireZeroOrOne = AppProcess.requireExitIn([0, 1]) + const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne)) + expect(okZero.exitCode).toBe(0) + const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne)) + expect(okOne.exitCode).toBe(1) + const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne))) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const reason = exit.cause.reasons[0] + if (reason && reason._tag === "Fail") { + expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError) + expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2) + } + } + }), + ) + + it.effect( + "truncates output when maxOutputBytes is set", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 }) + expect(result.exitCode).toBe(0) + expect(result.truncated).toBe(true) + expect(result.stdout.length).toBe(5) + expect(result.stdout.toString("utf8")).toBe("01234") + }), + ) + + it.effect( + "result includes command description", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')")) + expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`) + }), + ) + }) + + describe("inherited platform methods", () => { + it.effect( + "string returns stdout as string", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')")) + expect(out).toBe("hi\n") + }), + ) + + it.effect( + "lines returns the platform's array of lines", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')")) + expect(Array.from(out)).toEqual(["a", "b"]) + }), + ) + }) + + describe("runStream", () => { + it.live( + "emits lines incrementally and ends cleanly on exit 0", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc + .runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')")) + .pipe(Stream.runCollect) + expect(Array.from(result)).toEqual(["one", "two", "three"]) + }), + ) + + it.live( + "fails with AppProcessError when exit not in okExitCodes", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const exit = yield* Effect.exit( + svc.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }).pipe(Stream.runCollect), + ) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const reason = exit.cause.reasons[0] + if (reason && reason._tag === "Fail") { + expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError) + } + } + }), + ) + + it.live( + "okExitCodes allowlist treats non-zero as success", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc + .runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] }) + .pipe(Stream.runCollect) + expect(Array.from(result)).toEqual(["only"]) + }), + ) + + it.live( + "without okExitCodes, never fails on exit code", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const result = yield* svc.runStream(cmd("-e", "console.log('only'); process.exit(7)")).pipe(Stream.runCollect) + expect(Array.from(result)).toEqual(["only"]) + }), + ) + + it.live( + "AbortSignal interrupts the stream", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const controller = new AbortController() + setTimeout(() => controller.abort(), 50) + const exit = yield* Effect.exit( + svc + .runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), { + signal: controller.signal, + }) + .pipe(Stream.runCollect), + ) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + describe("spawn (inherited)", () => { + it.live( + "returns the platform ChildProcessHandle for advanced use", + Effect.scoped( + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)")) + expect(yield* handle.isRunning).toBe(true) + yield* handle.kill() + }), + ), + ) + }) +}) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 1b624800e..23f2d7027 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -291,25 +291,19 @@ const main = Effect.gen(function* () { if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) }) + ensureLoopbackNoProxy() + useEnvProxy() + logger.log("spawning sidecar", { url }) const { listener, health } = yield* Effect.promise(() => - spawnLocalServer( - hostname, - port, - password, - () => { - ensureLoopbackNoProxy() - useEnvProxy() - }, - { - needsMigration, - userDataPath: app.getPath("userData"), - onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), - onStdout: (message) => logger.log("sidecar stdout", { message }), - onStderr: (message) => logger.warn("sidecar stderr", { message }), - onExit: (code) => logger.warn("sidecar exited", { code }), - }, - ), + spawnLocalServer(hostname, port, password, { + needsMigration, + userDataPath: app.getPath("userData"), + onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress), + onStdout: (message) => logger.log("sidecar stdout", { message }), + onStderr: (message) => logger.warn("sidecar stderr", { message }), + onExit: (code) => logger.warn("sidecar exited", { code }), + }), ) server = listener yield* Deferred.succeed(serverReady, { diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 909138b89..cfdafdc67 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -70,10 +70,8 @@ export async function spawnLocalServer( hostname: string, port: number, password: string, - configureEnv: () => void, options: SpawnLocalServerOptions, ) { - configureEnv?.() const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js") const child = utilityProcess.fork(sidecar, [], { cwd: process.cwd(), diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index fb8cd3029..a39171462 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,9 +1,12 @@ import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" -import { fn } from "@opencode-ai/core/util/fn" import { iife } from "@opencode-ai/core/util/iife" import z from "zod" import { Storage } from "./storage" +function fn(schema: T, cb: (input: z.infer) => Result) { + return (input: z.infer) => cb(schema.parse(input)) +} + export namespace Share { export const Info = z.object({ id: z.string(), diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index b12afce27..7cfb2bb4a 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -15,7 +15,6 @@ import { Binary } from "@opencode-ai/core/util/binary" import { NamedError } from "@opencode-ai/core/util/error" import { DateTime } from "luxon" import { createStore } from "solid-js/store" -import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" import { MessageNav } from "@opencode-ai/ui/message-nav" @@ -33,13 +32,28 @@ const ClientOnlyWorkerPoolProvider = clientOnly(() => })), ) -const SessionDataMissingError = NamedError.create( - "SessionDataMissingError", - z.object({ - sessionID: z.string(), - message: z.string().optional(), - }), -) +class SessionDataMissingError extends NamedError { + public override readonly name = "SessionDataMissingError" + + constructor( + public readonly data: { sessionID: string; message?: string }, + options?: ErrorOptions, + ) { + super("SessionDataMissingError", options) + } + + static isInstance(input: unknown): input is SessionDataMissingError { + return NamedError.hasName(input, "SessionDataMissingError") + } + + schema(): never { + throw new Error("SessionDataMissingError does not expose a schema") + } + + toObject() { + return { name: this.name, data: this.data } + } +} const getData = query(async (shareID) => { "use server" diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 9680a53aa..088db5be2 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -298,6 +298,7 @@ declare module "sst" { "EnterpriseStorage": cloudflare.R2Bucket "GatewayKv": cloudflare.KVNamespace "LogProcessor": cloudflare.Service + "Stat": cloudflare.Service "ZenData": cloudflare.R2Bucket "ZenDataNew": cloudflare.R2Bucket } diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 9680a53aa..088db5be2 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -298,6 +298,7 @@ declare module "sst" { "EnterpriseStorage": cloudflare.R2Bucket "GatewayKv": cloudflare.KVNamespace "LogProcessor": cloudflare.Service + "Stat": cloudflare.Service "ZenData": cloudflare.R2Bucket "ZenDataNew": cloudflare.R2Bucket } diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index f6aaed435..5920c9670 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -70,19 +70,15 @@ Cassettes are normal source files — review them, diff them, commit them. ## Request matching -By default, requests match on canonicalized method, URL, headers, and JSON -body (object keys sorted). Two dispatch strategies are available: +Replay walks the cassette in record order via an internal cursor: the Nth +request executed at runtime is served by the Nth recorded interaction, and +each one is validated as the cursor advances. Request equality is computed +on canonicalized method, URL, headers, and JSON body (object keys sorted). -- **`match`** (default) — find the first recorded interaction whose request - matches the incoming request. Same request twice returns the same response. -- **`sequential`** — return interactions in the order they were recorded, - validating each one matches as the cursor advances. Use for ordered flows - where the same URL is hit multiple times with meaningful state changes - (pagination, retries, polling). - -```ts -HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" }) -``` +This is deliberately strict — content-based dispatch was removed because +it silently returns the first recorded response for repeated identical +requests, masking state changes that retry/polling/cache-hit tests need to +observe. If you reorder requests in a test, re-record the cassette. Supply your own matcher via `match: (incoming, recorded) => boolean` for custom equivalence (e.g. ignoring a timestamp field in the body). @@ -194,7 +190,6 @@ type RecordReplayOptions = { directory?: string // default: /test/fixtures/recordings metadata?: Record // merged into cassette.metadata redactor?: Redactor // default: Redactor.defaults() - dispatch?: "match" | "sequential" // default: "match" match?: (incoming, recorded) => boolean // custom matcher } ``` @@ -211,4 +206,4 @@ type RecordReplayOptions = { | `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. | | `schema.ts` | Effect Schema definitions for the cassette JSON format. | | `storage.ts` | Path resolution, JSON encode/decode, sync existence check. | -| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. | +| `matching.ts` | Request matcher, canonicalization, sequential cursor, mismatch diagnostics. | diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index e6c3ccbc1..61193a013 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -11,7 +11,7 @@ import { UrlParams, } from "effect/unstable/http" import * as CassetteService from "./cassette" -import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching" +import { defaultMatcher, selectSequential, type RequestMatcher } from "./matching" import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" import { defaults, type Redactor } from "./redactor" import { redactUrl } from "./redaction" @@ -24,7 +24,6 @@ export interface RecordReplayOptions { readonly directory?: string readonly metadata?: CassetteMetadata readonly redactor?: Redactor - readonly dispatch?: "match" | "sequential" readonly match?: RequestMatcher } @@ -71,7 +70,6 @@ export const recordingLayer = ( const match = options.match ?? defaultMatcher const requested = options.mode ?? "auto" const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested - const sequential = options.dispatch === "sequential" const replay = yield* makeReplayState(cassetteService, name, httpInteractions) const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => @@ -119,14 +117,12 @@ export const recordingLayer = ( transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`), ), ) - const result = sequential - ? selectSequential(interactions, incoming, match, yield* replay.cursor) - : selectMatch(interactions, incoming, match) + const result = selectSequential(interactions, incoming, match, yield* replay.cursor) if (!result.interaction) return yield* Effect.fail( transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), ) - if (sequential) yield* replay.advance + yield* replay.advance return HttpClientResponse.fromWeb( request, new Response(decodeResponseBody(result.interaction.response), result.interaction.response), diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/matching.ts index 9af85a2f3..ab647ab37 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/matching.ts @@ -92,24 +92,6 @@ export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot return lines } -export const mismatchDetail = (interactions: ReadonlyArray, incoming: RequestSnapshot): string => { - if (interactions.length === 0) return "cassette has no recorded HTTP interactions" - const ranked = interactions - .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) - .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) - const best = ranked[0] - return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") -} - -export const selectMatch = ( - interactions: ReadonlyArray, - incoming: RequestSnapshot, - match: RequestMatcher, -): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { - const interaction = interactions.find((candidate) => match(incoming, candidate.request)) - return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) } -} - export const selectSequential = ( interactions: ReadonlyArray, incoming: RequestSnapshot, diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index 7613563fd..503f87ac5 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -230,19 +230,10 @@ describe("http-recorder", () => { ) }) - test("default matcher dispatches multi-interaction cassettes by request shape", async () => { - await run( - Effect.gen(function* () { - expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') - expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') - }), - ) - }) - - test("sequential dispatch returns recorded responses in order for identical requests", async () => { + test("replay returns recorded responses in order for identical requests", async () => { await runWith( "record-replay/retry", - { dispatch: "sequential" }, + {}, Effect.gen(function* () { expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') @@ -250,21 +241,8 @@ describe("http-recorder", () => { ) }) - test("default matcher returns the first match for identical requests", async () => { - await runWith( - "record-replay/retry", - {}, - Effect.gen(function* () { - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') - expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') - }), - ) - }) - - test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => { - await runWith( - "record-replay/multi-step", - { dispatch: "sequential" }, + test("replay reports cursor exhaustion when more requests are made than recorded", async () => { + await run( Effect.gen(function* () { yield* post("https://example.test/echo", { step: 1 }) yield* post("https://example.test/echo", { step: 2 }) @@ -274,10 +252,8 @@ describe("http-recorder", () => { ) }) - test("sequential dispatch still validates each recorded request", async () => { - await runWith( - "record-replay/multi-step", - { dispatch: "sequential" }, + test("replay validates each recorded request in order", async () => { + await run( Effect.gen(function* () { yield* post("https://example.test/echo", { step: 1 }) const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) @@ -331,14 +307,13 @@ describe("http-recorder", () => { } }) - test("mismatch diagnostics show closest redacted request differences", async () => { + test("mismatch diagnostics show redacted request differences against the expected interaction", async () => { await run( Effect.gen(function* () { const exit = yield* Effect.exit( post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), ) const message = failureText(exit) - expect(message).toContain("closest interaction: #1") expect(message).toContain("url:") expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") expect(message).toContain("body:") diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index b20847da3..16a58fd86 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -8,6 +8,10 @@ - In `Effect.gen`, yield yieldable errors directly (`return yield* new MyError(...)`) instead of `Effect.fail(new MyError(...))`. - Use `Effect.void` instead of `Effect.succeed(undefined)` when the successful value is intentionally void. +## Conventions + +Per-type constructors live on the type's namespace, not as top-level re-exports. Use `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for the request-shaped call API: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.model`, `LLM.updateRequest`, `LLM.generateObject`. Two ways to construct the same thing is one too many. + ## Tests - Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers. @@ -166,12 +170,12 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i Tool loops are represented in common messages and events: ```ts -const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }) -const result = LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }) +const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }) +const result = Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }) const followUp = LLM.request({ model, - messages: [LLM.user("Weather?"), LLM.assistant([call]), result], + messages: [Message.user("Weather?"), Message.assistant([call]), result], }) ``` @@ -289,6 +293,6 @@ Filters apply in replay and record mode. Combine them with `RECORD=true` when re **Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text. -**Matching strategies.** Replay defaults to structural matching, which finds an interaction by comparing method, URL, allow-listed headers, and the canonical JSON body. This is the right choice for tool loops because each round's request differs (the message history grows). For scenarios where successive requests are byte-identical and expect different responses (retries, polling), pass `dispatch: "sequential"` in `RecordReplayOptions` — replay then walks the cassette in record order via an internal cursor. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. +**Matching strategy.** Replay walks the cassette in record order via an internal cursor: the Nth runtime request is served by the Nth recorded interaction, and each one is validated by comparing method, URL, allow-listed headers, and the canonical JSON body. This handles tool loops (each round's request differs as history grows) and retry/polling scenarios (successive byte-identical requests with different responses) uniformly. If a test reorders its requests, re-record the cassette. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed. diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index a9adecf36..429ac4824 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -78,7 +78,7 @@ const streamText = LLM.stream(request).pipe( Stream.tap((event) => Effect.sync(() => { if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) - if (event.type === "request-finish") process.stdout.write(`\nfinish: ${event.reason}\n`) + if (event.type === "finish") process.stdout.write(`\nfinish: ${event.reason}\n`) }), ), Stream.runDrain, @@ -185,7 +185,7 @@ const FakeProtocol = Protocol.make({ event: Schema.String, initial: () => undefined, step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), - onHalt: () => [{ type: "request-finish", reason: "stop" }], + onHalt: () => [{ type: "finish", reason: "stop" }], }, }) diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index f4adf4859..acf73b360 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -17,6 +17,7 @@ export type { ExecutableTools, Tool as ToolShape, ToolExecute, + ToolExecuteContext, Tools, ToolSchema, } from "./tool" diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index bca78c888..6f6728216 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -44,32 +44,8 @@ export type RequestInput = Omit< export const limits = modelLimits -export const text = Message.text - -export const system = SystemPart.make - -export const message = Message.make - -export const user = Message.user - -export const assistant = Message.assistant - export const model = modelRef -export const toolDefinition = ToolDefinition.make - -export const toolCall = ToolCallPart.make - -export const toolResult = ToolResultPart.make - -export const toolMessage = Message.tool - -export const toolChoiceName = ToolChoice.named - -export const toolChoice = ToolChoice.make - -export const generation = GenerationOptions.make - export const generate = LLMClient.generate export const stream = LLMClient.stream @@ -95,10 +71,10 @@ export const request = (input: RequestInput) => { return new LLMRequest({ ...rest, system: SystemPart.content(requestSystem), - messages: [...(messages?.map(message) ?? []), ...(prompt === undefined ? [] : [user(prompt)])], - tools: tools?.map(toolDefinition) ?? [], - toolChoice: requestToolChoice ? toolChoice(requestToolChoice) : undefined, - generation: requestGeneration === undefined ? undefined : generation(requestGeneration), + messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])], + tools: tools?.map(ToolDefinition.make) ?? [], + toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined, + generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration), providerOptions: requestProviderOptions, http: requestHttp === undefined ? undefined : HttpOptions.make(requestHttp), }) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index d893888fd..e27af1842 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -17,6 +17,7 @@ import { } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import * as Cache from "./utils/cache" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -190,6 +191,7 @@ type AnthropicEvent = Schema.Schema.Type interface ParserState { readonly tools: ToolStream.State readonly usage?: Usage + readonly lifecycle: Lifecycle.State } const invalid = ProviderShared.invalidRequest @@ -500,37 +502,45 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes if (!block) return [state, NO_EVENTS] if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) return [ { ...state, + lifecycle, tools: ToolStream.start(state.tools, event.index, { id: block.id ?? String(event.index), name: block.name ?? "", providerExecuted: block.type === "server_tool_use", }), }, - NO_EVENTS, + [...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })], ] } if (block.type === "text" && block.text) { - return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]] + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) }, + events, + ] } if (block.type === "thinking" && block.thinking) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.reasoningDelta({ - id: `reasoning-${event.index ?? 0}`, - text: block.thinking, - }), - ], + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking), + }, + events, ] } const result = serverToolResultEvent(block) - return [state, result ? [result] : NO_EVENTS] + if (!result) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]] } const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* ( @@ -540,25 +550,37 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f const delta = event.delta if (delta?.type === "text_delta" && delta.text) { - return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) }, + events, + ] satisfies StepResult } if (delta?.type === "thinking_delta" && delta.thinking) { + const events: LLMEvent[] = [] return [ - state, - [LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })], + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, delta.thinking), + }, + events, ] satisfies StepResult } if (delta?.type === "signature_delta" && delta.signature) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.reasoningEnd({ - id: `reasoning-${event.index ?? 0}`, - providerMetadata: anthropicMetadata({ signature: delta.signature }), - }), - ], + { + ...state, + lifecycle: Lifecycle.reasoningEnd( + state.lifecycle, + events, + `reasoning-${event.index ?? 0}`, + anthropicMetadata({ signature: delta.signature }), + ), + }, + events, ] satisfies StepResult } @@ -572,7 +594,10 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f "Anthropic Messages tool argument delta is missing its tool call", ) if (ToolStream.isError(result)) return yield* result - return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult } return [state, NO_EVENTS] satisfies StepResult @@ -584,23 +609,30 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun ) { if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index) - return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length + ? Lifecycle.stepStart(state.lifecycle, events) + : Lifecycle.reasoningEnd( + Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`), + events, + `reasoning-${event.index}`, + ) + events.push(...resultEvents) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult }) const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => { const usage = mergeUsage(state.usage, mapUsage(event.usage)) - return [ - { ...state, usage }, - [ - LLMEvent.requestFinish({ - reason: mapFinishReason(event.delta?.stop_reason), - usage, - providerMetadata: event.delta?.stop_sequence - ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) - : undefined, - }), - ], - ] + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(event.delta?.stop_reason), + usage, + providerMetadata: event.delta?.stop_sequence + ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) + : undefined, + }) + return [{ ...state, lifecycle, usage }, events] } const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ @@ -634,7 +666,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(AnthropicEvent), - initial: () => ({ tools: ToolStream.empty() }), + initial: () => ({ tools: ToolStream.empty(), lifecycle: Lifecycle.initial() }), step, }, }) diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index f561a6d7c..7f5647c4a 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -17,6 +17,7 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared" import { BedrockAuth, type Credentials as BedrockCredentials } from "./utils/bedrock-auth" import { BedrockCache } from "./utils/bedrock-cache" import { BedrockMedia } from "./utils/bedrock-media" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "bedrock-converse" @@ -420,45 +421,64 @@ interface ParserState { // `metadata` (carries usage). Hold the terminal event in state so `onHalt` // can emit exactly one finish after both chunks have had a chance to arrive. readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined + readonly hasToolCalls: boolean + readonly lifecycle: Lifecycle.State } const step = (state: ParserState, event: BedrockEvent) => Effect.gen(function* () { if (event.contentBlockStart?.start?.toolUse) { const index = event.contentBlockStart.contentBlockIndex + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) return [ { ...state, + lifecycle, tools: ToolStream.start(state.tools, index, { id: event.contentBlockStart.start.toolUse.toolUseId, name: event.contentBlockStart.start.toolUse.name, }), }, - [], + [ + ...events, + LLMEvent.toolInputStart({ + id: event.contentBlockStart.start.toolUse.toolUseId, + name: event.contentBlockStart.start.toolUse.name, + }), + ], ] as const } if (event.contentBlockDelta?.delta?.text) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.textDelta({ - id: `text-${event.contentBlockDelta.contentBlockIndex}`, - text: event.contentBlockDelta.delta.text, - }), - ], + { + ...state, + lifecycle: Lifecycle.textDelta( + state.lifecycle, + events, + `text-${event.contentBlockDelta.contentBlockIndex}`, + event.contentBlockDelta.delta.text, + ), + }, + events, ] as const } if (event.contentBlockDelta?.delta?.reasoningContent?.text) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.reasoningDelta({ - id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`, - text: event.contentBlockDelta.delta.reasoningContent.text, - }), - ], + { + ...state, + lifecycle: Lifecycle.reasoningDelta( + state.lifecycle, + events, + `reasoning-${event.contentBlockDelta.contentBlockIndex}`, + event.contentBlockDelta.delta.reasoningContent.text, + ), + }, + events, ] as const } @@ -472,12 +492,33 @@ const step = (state: ParserState, event: BedrockEvent) => "Bedrock Converse tool delta is missing its tool call", ) if (ToolStream.isError(result)) return yield* result - return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] as const } if (event.contentBlockStop) { const result = yield* ToolStream.finish(ADAPTER, state.tools, event.contentBlockStop.contentBlockIndex) - return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length + ? Lifecycle.stepStart(state.lifecycle, events) + : Lifecycle.reasoningEnd( + Lifecycle.textEnd(state.lifecycle, events, `text-${event.contentBlockStop.contentBlockIndex}`), + events, + `reasoning-${event.contentBlockStop.contentBlockIndex}`, + ) + events.push(...resultEvents) + return [ + { + ...state, + hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls, + lifecycle, + tools: result.tools, + }, + events, + ] as const } if (event.messageStop) { @@ -517,7 +558,15 @@ const framing = BedrockEventStream.framing(ADAPTER) const onHalt = (state: ParserState): ReadonlyArray => state.pendingFinish - ? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })] + ? (() => { + const events: LLMEvent[] = [] + Lifecycle.finish(state.lifecycle, events, { + reason: + state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason, + usage: state.pendingFinish.usage, + }) + return events + })() : [] // ============================================================================= @@ -535,7 +584,12 @@ export const protocol = Protocol.make({ }, stream: { event: BedrockEvent, - initial: () => ({ tools: ToolStream.empty(), pendingFinish: undefined }), + initial: () => ({ + tools: ToolStream.empty(), + pendingFinish: undefined, + hasToolCalls: false, + lifecycle: Lifecycle.initial(), + }), step, onHalt, }, diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 0ee88f3be..6e0b82abb 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -16,6 +16,7 @@ import { } from "../schema" import { JsonObject, optionalArray, ProviderShared } from "./shared" import { GeminiToolSchema } from "./utils/gemini-tool-schema" +import { Lifecycle } from "./utils/lifecycle" const ADAPTER = "gemini" export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" @@ -134,10 +135,9 @@ interface ParserState { readonly hasToolCalls: boolean readonly nextToolCallId: number readonly usage?: Usage + readonly lifecycle: Lifecycle.State } -const invalid = ProviderShared.invalidRequest - const mediaData = ProviderShared.mediaBytes // ============================================================================= @@ -324,7 +324,14 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean const finish = (state: ParserState): ReadonlyArray => state.finishReason || state.usage - ? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })] + ? (() => { + const events: LLMEvent[] = [] + Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(state.finishReason, state.hasToolCalls), + usage: state.usage, + }) + return events + })() : [] const step = (state: ParserState, event: GeminiEvent) => { @@ -341,21 +348,21 @@ const step = (state: ParserState, event: GeminiEvent) => { const events: LLMEvent[] = [] let hasToolCalls = nextState.hasToolCalls + let lifecycle = nextState.lifecycle let nextToolCallId = nextState.nextToolCallId for (const part of candidate.content.parts) { if ("text" in part && part.text.length > 0) { - events.push( - part.thought - ? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text }) - : LLMEvent.textDelta({ id: "text-0", text: part.text }), - ) + lifecycle = part.thought + ? Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", part.text) + : Lifecycle.textDelta(lifecycle, events, "text-0", part.text) continue } if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` + lifecycle = Lifecycle.stepStart(lifecycle, events) events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input })) hasToolCalls = true } @@ -365,6 +372,7 @@ const step = (state: ParserState, event: GeminiEvent) => { { ...nextState, hasToolCalls, + lifecycle, nextToolCallId, finishReason: candidate.finishReason ?? nextState.finishReason, }, @@ -388,7 +396,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(GeminiEvent), - initial: () => ({ hasToolCalls: false, nextToolCallId: 0 }), + initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }), step, onHalt: finish, }, diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 133adb503..470a1473c 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -16,6 +16,7 @@ import { } from "../schema" import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-chat" @@ -147,6 +148,7 @@ interface ParserState { readonly toolCallEvents: ReadonlyArray readonly usage?: Usage readonly finishReason?: FinishReason + readonly lifecycle: Lifecycle.State } const invalid = ProviderShared.invalidRequest @@ -321,7 +323,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const toolDeltas = delta?.tool_calls ?? [] let tools = state.tools - if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content })) + let lifecycle = state.lifecycle + + if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) for (const tool of toolDeltas) { const result = ToolStream.appendOrStart( @@ -333,7 +337,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) => ) if (ToolStream.isError(result)) return yield* result tools = result.tools - if (result.event) events.push(result.event) + if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events) + events.push(...result.events) } // Finalize accumulated tool inputs eagerly when finish_reason arrives so @@ -349,15 +354,20 @@ const step = (state: ParserState, event: OpenAIChatEvent) => toolCallEvents: finished?.events ?? state.toolCallEvents, usage, finishReason, + lifecycle, }, events, ] as const }) const finishEvents = (state: ParserState): ReadonlyArray => { + const events: LLMEvent[] = [] const hasToolCalls = state.toolCallEvents.length > 0 const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason - return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])] + const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...state.toolCallEvents) + if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage }) + return events } // ============================================================================= @@ -377,7 +387,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIChatEvent), - initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [] }), + initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [], lifecycle: Lifecycle.initial() }), step, onHalt: finishEvents, }, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 035cc0771..7cf734f02 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -17,6 +17,7 @@ import { } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-responses" @@ -165,6 +166,7 @@ type OpenAIResponsesEvent = Schema.Schema.Type interface ParserState { readonly tools: ToolStream.State readonly hasFunctionCall: boolean + readonly lifecycle: Lifecycle.State } const invalid = ProviderShared.invalidRequest @@ -378,30 +380,39 @@ type StepResult = readonly [ParserState, ReadonlyArray] const NO_EVENTS: StepResult["1"] = [] // `response.completed` / `response.incomplete` are clean finishes that emit a -// `request-finish` event; `response.failed` is a hard failure that emits a +// `finish` event; `response.failed` is a hard failure that emits a // `provider-error`. All three end the stream — kept in one set so `step` and // the protocol's `terminal` predicate stay in sync. const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] - return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]] + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) }, + events, + ] } const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const item = event.item if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS] + const providerMetadata = openaiMetadata({ itemId: item.id }) + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) return [ { + ...state, + lifecycle, hasFunctionCall: state.hasFunctionCall, tools: ToolStream.start(state.tools, item.id, { id: item.call_id ?? item.id, name: item.name ?? "", input: item.arguments ?? "", - providerMetadata: openaiMetadata({ itemId: item.id }), + providerMetadata, }), }, - NO_EVENTS, + [...events, LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata })], ] } @@ -418,10 +429,10 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallAr "OpenAI Responses tool argument delta is missing its tool call", ) if (ToolStream.isError(result)) return yield* result - return [ - { hasFunctionCall: state.hasFunctionCall, tools: result.tools }, - result.event ? [result.event] : NO_EVENTS, - ] satisfies StepResult + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult }) const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* ( @@ -440,33 +451,46 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* item.arguments === undefined ? yield* ToolStream.finish(ADAPTER, tools, item.id) : yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments) + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...resultEvents) return [ - { hasFunctionCall: result.event ? true : state.hasFunctionCall, tools: result.tools }, - result.event ? [result.event] : NO_EVENTS, + { + ...state, + lifecycle, + hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall, + tools: result.tools, + }, + events, ] satisfies StepResult } - if (isHostedToolItem(item)) return [state, hostedToolEvents(item)] satisfies StepResult + if (isHostedToolItem(item)) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + events.push(...hostedToolEvents(item)) + return [{ ...state, lifecycle }, events] satisfies StepResult + } return [state, NO_EVENTS] satisfies StepResult }) -const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ - state, - [ - LLMEvent.requestFinish({ - reason: mapFinishReason(event, state.hasFunctionCall), - usage: mapUsage(event.response?.usage), - providerMetadata: - event.response?.id || event.response?.service_tier - ? openaiMetadata({ - responseId: event.response.id, - serviceTier: event.response.service_tier, - }) - : undefined, - }), - ], -] +const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(event, state.hasFunctionCall), + usage: mapUsage(event.response?.usage), + providerMetadata: + event.response?.id || event.response?.service_tier + ? openaiMetadata({ + responseId: event.response.id, + serviceTier: event.response.service_tier, + }) + : undefined, + }) + return [{ ...state, lifecycle }, events] +} const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, @@ -506,7 +530,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIResponsesEvent), - initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty() }), + initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty(), lifecycle: Lifecycle.initial() }), step, terminal: (event) => TERMINAL_TYPES.has(event.type), }, diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts new file mode 100644 index 000000000..c249d75ce --- /dev/null +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -0,0 +1,88 @@ +import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema" + +export interface State { + readonly stepStarted: boolean + readonly text: ReadonlySet + readonly reasoning: ReadonlySet +} + +export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() }) + +export const stepStart = (state: State, events: LLMEvent[]): State => { + if (state.stepStarted) return state + events.push(LLMEvent.stepStart({ index: 0 })) + return { ...state, stepStarted: true } +} + +export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { + const stepped = stepStart(state, events) + if (stepped.text.has(id)) { + events.push(LLMEvent.textDelta({ id, text })) + return stepped + } + events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text })) + return { ...stepped, text: new Set([...stepped.text, id]) } +} + +export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { + const stepped = stepStart(state, events) + if (stepped.reasoning.has(id)) { + events.push(LLMEvent.reasoningDelta({ id, text })) + return stepped + } + events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text })) + return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) } +} + +export const reasoningEnd = ( + state: State, + events: LLMEvent[], + id: string, + providerMetadata?: ProviderMetadata, +): State => { + if (!state.reasoning.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.reasoningEnd({ id, providerMetadata })) + const reasoning = new Set(stepped.reasoning) + reasoning.delete(id) + return { ...stepped, reasoning } +} + +export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { + if (!state.text.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.textEnd({ id, providerMetadata })) + const text = new Set(stepped.text) + text.delete(id) + return { ...stepped, text } +} + +const closeOpenBlocks = (state: State, events: LLMEvent[]): State => { + for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id })) + for (const id of state.text) events.push(LLMEvent.textEnd({ id })) + return { ...state, text: new Set(), reasoning: new Set() } +} + +export const finish = ( + state: State, + events: LLMEvent[], + input: { + readonly reason: FinishReason + readonly usage?: Usage + readonly providerMetadata?: ProviderMetadata + }, +): State => { + const stepped = closeOpenBlocks(stepStart(state, events), events) + events.push( + LLMEvent.stepFinish({ + index: 0, + reason: input.reason, + usage: input.usage, + providerMetadata: input.providerMetadata, + }), + LLMEvent.finish(input), + ) + return { ...stepped, stepStarted: false } +} + +export * as Lifecycle from "./lifecycle" diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts index aa9c70f01..8e07a64bf 100644 --- a/packages/llm/src/protocols/utils/tool-stream.ts +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" +import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema" import { eventError, parseToolInput, type ToolAccumulator } from "../shared" type StreamKey = string | number @@ -27,13 +27,13 @@ export type State = Partial> /** * Result of adding argument text to one pending tool call. It returns both the * next `tools` state and the updated `tool` because parsers often need the - * current id/name immediately. `event` is present only when new text arrived; - * metadata-only deltas update identity without emitting `tool-input-delta`. + * current id/name immediately. `events` contains lifecycle and delta events + * produced by the append; metadata-only deltas update identity without output. */ export interface AppendOutcome { readonly tools: State readonly tool: PendingTool - readonly event?: ToolInputDelta + readonly events: ReadonlyArray } /** Create empty accumulator state for one provider stream. */ @@ -49,7 +49,14 @@ const withoutTool = (tools: State, key: K): State => return next } -const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => +const inputStart = (tool: PendingTool) => + LLMEvent.toolInputStart({ + id: tool.id, + name: tool.name, + providerMetadata: tool.providerMetadata, + }) + +const inputDelta = (tool: PendingTool, text: string) => LLMEvent.toolInputDelta({ id: tool.id, name: tool.name, @@ -76,11 +83,16 @@ const appendTool = ( key: K, tool: PendingTool, text: string, -): AppendOutcome => ({ - tools: withTool(tools, key, tool), - tool, - event: text.length === 0 ? undefined : inputDelta(tool, text), -}) +): AppendOutcome => { + const events: LLMEvent[] = [] + if (!tools[key]) events.push(inputStart(tool)) + if (text.length > 0) events.push(inputDelta(tool, text)) + return { + tools: withTool(tools, key, tool), + tool, + events, + } +} export const isError = (result: AppendOutcome | LLMError): result is LLMError => result instanceof LLMError @@ -121,7 +133,8 @@ export const appendOrStart = ( providerExecuted: current?.providerExecuted, providerMetadata: current?.providerMetadata, } - if (current && delta.text.length === 0 && current.id === id && current.name === name) return { tools, tool: current } + if (current && delta.text.length === 0 && current.id === id && current.name === name) + return { tools, tool: current, events: [] } return appendTool(tools, key, tool, delta.text) } @@ -139,7 +152,7 @@ export const appendExisting = ( ): AppendOutcome | LLMError => { const current = tools[key] if (!current) return eventError(route, missingToolMessage) - if (text.length === 0) return { tools, tool: current } + if (text.length === 0) return { tools, tool: current, events: [] } return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text) } @@ -152,7 +165,13 @@ export const finish = (route: string, tools: State, key: Effect.gen(function* () { const tool = tools[key] if (!tool) return { tools } - return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool) } + return { + tools: withoutTool(tools, key), + events: [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + yield* toolCall(route, tool), + ], + } }) /** @@ -164,7 +183,13 @@ export const finishWithInput = (route: string, tools: State Effect.gen(function* () { const tool = tools[key] if (!tool) return { tools } - return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool, input) } + return { + tools: withoutTool(tools, key), + events: [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + yield* toolCall(route, tool, input), + ], + } }) /** @@ -179,7 +204,14 @@ export const finishAll = (route: string, tools: State) = ) return { tools: empty(), - events: yield* Effect.forEach(pending, (tool) => toolCall(route, tool)), + events: yield* Effect.forEach(pending, (tool) => + toolCall(route, tool).pipe( + Effect.map((call) => [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + call, + ]), + ), + ).pipe(Effect.map((events) => events.flat())), } }) diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 6e6bb1541..6a088dc87 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids" +import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ModelRef } from "./options" import { ToolResultValue } from "./messages" @@ -66,14 +66,13 @@ export class Usage extends Schema.Class("LLM.Usage")({ get visibleOutputTokens() { return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0)) } + + static from(input: UsageInput) { + return input instanceof Usage ? input : new Usage(input) + } } -export const RequestStart = Schema.Struct({ - type: Schema.tag("request-start"), - id: ResponseID, - model: ModelRef, -}).annotate({ identifier: "LLM.Event.RequestStart" }) -export type RequestStart = Schema.Schema.Type +export type UsageInput = Usage | ConstructorParameters[0] export const StepStart = Schema.Struct({ type: Schema.tag("step-start"), @@ -185,13 +184,13 @@ export const StepFinish = Schema.Struct({ }).annotate({ identifier: "LLM.Event.StepFinish" }) export type StepFinish = Schema.Schema.Type -export const RequestFinish = Schema.Struct({ - type: Schema.tag("request-finish"), +export const Finish = Schema.Struct({ + type: Schema.tag("finish"), reason: FinishReason, usage: Schema.optional(Usage), providerMetadata: Schema.optional(ProviderMetadata), -}).annotate({ identifier: "LLM.Event.RequestFinish" }) -export type RequestFinish = Schema.Schema.Type +}).annotate({ identifier: "LLM.Event.Finish" }) +export type Finish = Schema.Schema.Type export const ProviderErrorEvent = Schema.Struct({ type: Schema.tag("provider-error"), @@ -202,7 +201,6 @@ export const ProviderErrorEvent = Schema.Struct({ export type ProviderErrorEvent = Schema.Schema.Type const llmEventTagged = Schema.Union([ - RequestStart, StepStart, TextStart, TextDelta, @@ -217,13 +215,15 @@ const llmEventTagged = Schema.Union([ ToolResult, ToolError, StepFinish, - RequestFinish, + Finish, ProviderErrorEvent, ]).pipe(Schema.toTaggedUnion("type")) type WithID = Omit & { readonly id: ID | string } +type WithUsage = Omit & { + readonly usage?: UsageInput +} -const responseID = (value: ResponseID | string) => ResponseID.make(value) const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value) const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) @@ -233,7 +233,6 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) * `events.filter(LLMEvent.guards["tool-call"])`. */ export const LLMEvent = Object.assign(llmEventTagged, { - requestStart: (input: WithID) => RequestStart.make({ ...input, id: responseID(input.id) }), stepStart: StepStart.make, textStart: (input: WithID) => TextStart.make({ ...input, id: contentBlockID(input.id) }), textDelta: (input: WithID) => TextDelta.make({ ...input, id: contentBlockID(input.id) }), @@ -252,11 +251,18 @@ export const LLMEvent = Object.assign(llmEventTagged, { toolCall: (input: WithID) => ToolCall.make({ ...input, id: toolCallID(input.id) }), toolResult: (input: WithID) => ToolResult.make({ ...input, id: toolCallID(input.id) }), toolError: (input: WithID) => ToolError.make({ ...input, id: toolCallID(input.id) }), - stepFinish: StepFinish.make, - requestFinish: RequestFinish.make, + stepFinish: (input: WithUsage) => + StepFinish.make({ + ...input, + usage: input.usage === undefined ? undefined : Usage.from(input.usage), + }), + finish: (input: WithUsage) => + Finish.make({ + ...input, + usage: input.usage === undefined ? undefined : Usage.from(input.usage), + }), providerError: ProviderErrorEvent.make, is: { - requestStart: llmEventTagged.guards["request-start"], stepStart: llmEventTagged.guards["step-start"], textStart: llmEventTagged.guards["text-start"], textDelta: llmEventTagged.guards["text-delta"], @@ -271,7 +277,7 @@ export const LLMEvent = Object.assign(llmEventTagged, { toolResult: llmEventTagged.guards["tool-result"], toolError: llmEventTagged.guards["tool-error"], stepFinish: llmEventTagged.guards["step-finish"], - requestFinish: llmEventTagged.guards["request-finish"], + finish: llmEventTagged.guards.finish, providerError: llmEventTagged.guards["provider-error"], }, }) diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index c6e716d45..d83dcc67a 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -12,6 +12,7 @@ import { ToolFailure, ToolResultPart, type ToolResultValue, + Usage, } from "./schema" import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool" @@ -72,19 +73,42 @@ export const stream = (options: StreamOptions): Stream.Strea tools: [...options.request.tools.filter((tool) => !runtimeToolNames.has(tool.name)), ...runtimeTools], }) - const loop = (request: LLMRequest, step: number): Stream.Stream => + const loop = ( + request: LLMRequest, + step: number, + usage: Usage | undefined, + providerMetadata: ProviderMetadata | undefined, + ): Stream.Stream => Stream.unwrap( Effect.gen(function* () { - const state: StepState = { assistantContent: [], toolCalls: [], finishReason: undefined } + const state: StepState = { + assistantContent: [], + toolCalls: [], + finishReason: undefined, + usage: undefined, + providerMetadata: undefined, + } const modelStream = options .stream(request) + .pipe(Stream.map((event) => indexStep(event, step))) .pipe(Stream.tap((event) => Effect.sync(() => accumulate(state, event)))) + .pipe(Stream.filter((event) => event.type !== "finish")) const continuation = Stream.unwrap( Effect.gen(function* () { - if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return Stream.empty - if (options.toolExecution === "none") return Stream.empty + const totalUsage = addUsage(usage, state.usage) + const totalProviderMetadata = mergeProviderMetadata(providerMetadata, state.providerMetadata) + const finishStream = Stream.fromIterable([ + LLMEvent.finish({ + reason: state.finishReason ?? "unknown", + usage: totalUsage, + providerMetadata: totalProviderMetadata, + }), + ]) + + if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return finishStream + if (options.toolExecution === "none") return finishStream const dispatched = yield* Effect.forEach( state.toolCalls, @@ -93,10 +117,14 @@ export const stream = (options: StreamOptions): Stream.Strea ) const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result))) - if (!options.stopWhen) return resultStream - if (options.stopWhen({ step, request })) return resultStream + if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream)) + if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream)) - return resultStream.pipe(Stream.concat(loop(followUpRequest(request, state, dispatched), step + 1))) + return resultStream.pipe( + Stream.concat( + loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata), + ), + ) }), ) @@ -104,13 +132,21 @@ export const stream = (options: StreamOptions): Stream.Strea }), ) - return loop(initialRequest, 0) + return loop(initialRequest, 0, undefined, undefined) +} + +const indexStep = (event: LLMEvent, index: number): LLMEvent => { + if (event.type === "step-start") return LLMEvent.stepStart({ index }) + if (event.type === "step-finish") return LLMEvent.stepFinish({ ...event, index }) + return event } interface StepState { assistantContent: ContentPart[] toolCalls: ToolCallPart[] finishReason: FinishReason | undefined + usage: Usage | undefined + providerMetadata: ProviderMetadata | undefined } const accumulate = (state: StepState, event: LLMEvent) => { @@ -154,9 +190,43 @@ const accumulate = (state: StepState, event: LLMEvent) => { ) return } - if (event.type === "request-finish") { - state.finishReason = event.reason + if (event.type === "step-finish") { + state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason + state.usage = addUsage(state.usage, event.usage) + state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata) + return } + if (event.type === "finish") { + state.finishReason ??= event.reason + state.usage ??= event.usage + state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata) + } +} + +const addUsage = (left: Usage | undefined, right: Usage | undefined) => { + if (!left) return right + if (!right) return left + type UsageKey = + | "inputTokens" + | "outputTokens" + | "nonCachedInputTokens" + | "cacheReadInputTokens" + | "cacheWriteInputTokens" + | "reasoningTokens" + | "totalTokens" + const sum = (key: UsageKey) => + left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0) + + return new Usage({ + inputTokens: sum("inputTokens"), + outputTokens: sum("outputTokens"), + nonCachedInputTokens: sum("nonCachedInputTokens"), + cacheReadInputTokens: sum("cacheReadInputTokens"), + cacheWriteInputTokens: sum("cacheWriteInputTokens"), + reasoningTokens: sum("reasoningTokens"), + totalTokens: sum("totalTokens"), + providerMetadata: mergeProviderMetadata(left.providerMetadata, right.providerMetadata), + }) } const sameProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => @@ -200,17 +270,17 @@ const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue), ), ) } -const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect => - tool._decode(input).pipe( +const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect => + tool._decode(call.input).pipe( Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), - Effect.flatMap((decoded) => tool.execute!(decoded)), + Effect.flatMap((decoded) => tool.execute!(decoded, { id: call.id, name: call.name })), Effect.flatMap((value) => tool._encode(value).pipe( Effect.mapError( diff --git a/packages/llm/src/tool.ts b/packages/llm/src/tool.ts index 311c8798b..df0a1cd3d 100644 --- a/packages/llm/src/tool.ts +++ b/packages/llm/src/tool.ts @@ -1,5 +1,5 @@ import { Effect, JsonSchema, Schema } from "effect" -import type { ToolDefinition as ToolDefinitionClass } from "./schema" +import type { ToolCallPart, ToolDefinition as ToolDefinitionClass } from "./schema" import { ToolDefinition, ToolFailure } from "./schema" /** @@ -8,9 +8,14 @@ import { ToolDefinition, ToolFailure } from "./schema" * beyond pure data conversion belongs in the handler closure. */ export type ToolSchema = Schema.Codec +export interface ToolExecuteContext { + readonly id: ToolCallPart["id"] + readonly name: ToolCallPart["name"] +} export type ToolExecute, Success extends ToolSchema> = ( params: Schema.Schema.Type, + context?: ToolExecuteContext, ) => Effect.Effect, ToolFailure> /** @@ -61,7 +66,7 @@ type TypedToolConfig = { type DynamicToolConfig = { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema - readonly execute?: (params: unknown) => Effect.Effect + readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect } /** @@ -110,7 +115,7 @@ export function make, Success extends ToolSch export function make(config: { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema - readonly execute: (params: unknown) => Effect.Effect + readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect }): AnyExecutableTool export function make(config: { readonly description: string diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 5ac8b9d81..80349a5ae 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -51,7 +51,7 @@ const request = LLM.request({ const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => event.type === "finish" - ? { type: "request-finish", reason: event.reason } + ? { type: "finish", reason: event.reason } : { type: "text-delta", id: "text-0", text: event.text } const fakeProtocol = Protocol.make({ @@ -112,8 +112,8 @@ describe("llm route", () => { const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) const response = yield* llm.generate(request) - expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) - expect(response.events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"]) + expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"]) }), ) diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts index e742ca5e6..ac700b58f 100644 --- a/packages/llm/test/cache-policy.test.ts +++ b/packages/llm/test/cache-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM } from "../src" +import { CacheHint, LLM, Message } from "../src" import { LLMClient } from "../src/route" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as BedrockConverse from "../src/protocols/bedrock-converse" @@ -59,7 +59,11 @@ describe("applyCachePolicy", () => { model: anthropicModel, system: "Sys A", tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], - messages: [LLM.user("first user"), LLM.assistant("assistant reply"), LLM.user("latest user message")], + messages: [ + Message.user("first user"), + Message.assistant("assistant reply"), + Message.user("latest user message"), + ], cache: "auto", }), ) @@ -122,7 +126,7 @@ describe("applyCachePolicy", () => { model: bedrockModel, system: "Sys", tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], - messages: [LLM.user("first user"), LLM.assistant("reply"), LLM.user("latest user")], + messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")], cache: "auto", }), ) @@ -221,7 +225,7 @@ describe("applyCachePolicy", () => { const prepared = yield* LLMClient.prepare( LLM.request({ model: anthropicModel, - messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2"), LLM.assistant("a2")], + messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")], cache: { messages: { tail: 2 } }, }), ) @@ -239,7 +243,7 @@ describe("applyCachePolicy", () => { const prepared = yield* LLMClient.prepare( LLM.request({ model: anthropicModel, - messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2")], + messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")], cache: { messages: "latest-assistant" }, }), ) diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index e9ef58afa..a20c48411 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { LLM, LLMResponse } from "../src" -import { LLMRequest, Message, ModelRef, ToolChoice, ToolDefinition } from "../src/schema" +import { LLMRequest, Message, ModelRef, ToolCallPart, ToolChoice, ToolDefinition, ToolResultPart } from "../src/schema" describe("llm constructors", () => { test("builds canonical schema classes from ergonomic input", () => { @@ -28,7 +28,7 @@ describe("llm constructors", () => { }) const updated = LLM.updateRequest(base, { generation: { maxTokens: 20 }, - messages: [...base.messages, LLM.assistant("Hi.")], + messages: [...base.messages, Message.assistant("Hi.")], }) expect(updated).toBeInstanceOf(LLMRequest) @@ -70,7 +70,7 @@ describe("llm constructors", () => { model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), prompt: "Say hello.", }) - const updated = LLMRequest.update(base, { messages: [...base.messages, LLM.assistant("Hi.")] }) + const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] }) expect(updated).toBeInstanceOf(LLMRequest) expect(updated.id).toBe("req_1") @@ -91,18 +91,18 @@ describe("llm constructors", () => { }) test("builds tool choices from names and tools", () => { - const tool = LLM.toolDefinition({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) + const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) expect(tool).toBeInstanceOf(ToolDefinition) - expect(LLM.toolChoice("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) - expect(LLM.toolChoiceName("required")).toEqual(new ToolChoice({ type: "tool", name: "required" })) - expect(LLM.toolChoice(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + expect(ToolChoice.make("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + expect(ToolChoice.named("required")).toEqual(new ToolChoice({ type: "tool", name: "required" })) + expect(ToolChoice.make(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) }) test("builds tool choice modes from reserved strings", () => { - expect(LLM.toolChoice("auto")).toEqual(new ToolChoice({ type: "auto" })) - expect(LLM.toolChoice("none")).toEqual(new ToolChoice({ type: "none" })) - expect(LLM.toolChoice("required")).toEqual(new ToolChoice({ type: "required" })) + expect(ToolChoice.make("auto")).toEqual(new ToolChoice({ type: "auto" })) + expect(ToolChoice.make("none")).toEqual(new ToolChoice({ type: "none" })) + expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" })) expect( LLM.request({ model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), @@ -113,11 +113,11 @@ describe("llm constructors", () => { }) test("builds assistant tool calls and tool result messages", () => { - const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }) - const result = LLM.toolResult({ id: "call_1", name: "lookup", result: { temperature: 72 } }) + const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }) + const result = ToolResultPart.make({ id: "call_1", name: "lookup", result: { temperature: 72 } }) - expect(LLM.assistant([call]).content).toEqual([call]) - expect(LLM.toolMessage(result).content).toEqual([ + expect(Message.assistant([call]).content).toEqual([call]) + expect(Message.tool(result).content).toEqual([ { type: "tool-result", id: "call_1", name: "lookup", result: { type: "json", value: { temperature: 72 } } }, ]) }) @@ -127,7 +127,7 @@ describe("llm constructors", () => { LLMResponse.text({ events: [ { type: "text-delta", id: "text-0", text: "hi" }, - { type: "request-finish", reason: "stop" }, + { type: "finish", reason: "stop" }, ], }), ).toBe("hi") diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts index cb144b1a5..68b7e0a4a 100644 --- a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts @@ -31,10 +31,9 @@ const recorded = recordedTests({ provider: "anthropic", protocol: "anthropic-messages", requires: ["ANTHROPIC_API_KEY"], - // Two identical requests in one cassette — match by recording order so the - // second call replays the cached-hit interaction. + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction. options: { - dispatch: "sequential", redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }), }, }) diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/llm/test/provider/anthropic-messages.recorded.test.ts index aa5b258d3..5fefae51d 100644 --- a/packages/llm/test/provider/anthropic-messages.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages.recorded.test.ts @@ -1,7 +1,7 @@ import { Redactor } from "@opencode-ai/http-recorder" import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMError } from "../../src" +import { LLM, LLMError, Message, ToolCallPart } from "../../src" import { LLMClient } from "../../src/route" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import { weatherToolName } from "../recorded-scenarios" @@ -16,12 +16,12 @@ const malformedToolOrderRequest = LLM.request({ id: "recorded_anthropic_malformed_tool_order", model, messages: [ - LLM.assistant([ - LLM.toolCall({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }), + Message.assistant([ + ToolCallPart.make({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }), { type: "text", text: "I will check the weather." }, ]), - LLM.toolMessage({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }), - LLM.user("Use that result to answer briefly."), + Message.tool({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }), + Message.user("Use that result to answer briefly."), ], tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }], }) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index a867d1659..71204bcd6 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM, LLMError, Usage } from "../../src" +import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" import { LLMClient } from "../../src/route" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import { it } from "../lib/effect" @@ -47,9 +47,9 @@ describe("Anthropic Messages route", () => { id: "req_tool_result", model, messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], cache: "none", }), @@ -77,7 +77,7 @@ describe("Anthropic Messages route", () => { LLM.request({ model, messages: [ - LLM.assistant([ + Message.assistant([ { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, ]), ], @@ -124,7 +124,7 @@ describe("Anthropic Messages route", () => { providerMetadata: { anthropic: { signature: "sig_1" } }, }) expect(response.events.at(-1)).toMatchObject({ - type: "request-finish", + type: "finish", reason: "stop", providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } }, }) @@ -146,24 +146,46 @@ describe("Anthropic Messages route", () => { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }), ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + cacheWriteInputTokens: undefined, + totalTokens: 6, + providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, + }) expect(response.toolCalls).toEqual([ - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, ]) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup" }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, { - type: "request-finish", + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, + { + type: "finish", reason: "tool-calls", - usage: new Usage({ - inputTokens: 5, - outputTokens: 1, - nonCachedInputTokens: 5, - totalTokens: 6, - providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, - }), + providerMetadata: undefined, + usage, }, ]) }), @@ -253,7 +275,7 @@ describe("Anthropic Messages route", () => { providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, }) expect(response.text).toBe("Found it.") - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) }), ) @@ -304,8 +326,8 @@ describe("Anthropic Messages route", () => { id: "req_round_trip", model, messages: [ - LLM.user("Search for something."), - LLM.assistant([ + Message.user("Search for something."), + Message.assistant([ { type: "tool-call", id: "srvtoolu_abc", @@ -322,7 +344,7 @@ describe("Anthropic Messages route", () => { }, { type: "text", text: "Found it." }, ]), - LLM.user("Thanks."), + Message.user("Thanks."), ], }), ) @@ -355,7 +377,7 @@ describe("Anthropic Messages route", () => { id: "req_unknown_server_tool", model, messages: [ - LLM.assistant([ + Message.assistant([ { type: "tool-result", id: "srvtoolu_abc", @@ -378,7 +400,7 @@ describe("Anthropic Messages route", () => { LLM.request({ id: "req_media", model, - messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], }), ).pipe(Effect.flip) @@ -416,9 +438,9 @@ describe("Anthropic Messages route", () => { }, ], messages: [ - LLM.user("What's the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), - LLM.toolMessage({ + Message.user("What's the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 }, @@ -501,7 +523,7 @@ describe("Anthropic Messages route", () => { }, ], system: [{ type: "text", text: "system-tail", cache: hint }], - messages: [LLM.user([{ type: "text", text: "message-tail", cache: hint }])], + messages: [Message.user([{ type: "text", text: "message-tail", cache: hint }])], }), ) diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts index 16c44099c..2771046f8 100644 --- a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts +++ b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts @@ -38,9 +38,8 @@ const recorded = recordedTests({ provider: "amazon-bedrock", protocol: "bedrock-converse", requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], - // Two identical requests in one cassette — match by recording order so the - // second call replays the cached-hit interaction. - options: { dispatch: "sequential" }, + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction. }) describe("Bedrock Converse cache recorded", () => { diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 208b56527..ffdd6e800 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -2,7 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec" import { fromUtf8, toUtf8 } from "@smithy/util-utf8" import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM } from "../../src" +import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src" import { LLMClient } from "../../src/route" import * as BedrockConverse from "../../src/protocols/bedrock-converse" import { it } from "../lib/effect" @@ -94,7 +94,7 @@ describe("Bedrock Converse route", () => { inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, }, ], - toolChoice: LLM.toolChoice({ type: "required" }), + toolChoice: ToolChoice.make({ type: "required" }), }), ) @@ -124,9 +124,9 @@ describe("Bedrock Converse route", () => { id: "req_history", model, messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }), ], cache: "none", }), @@ -169,12 +169,12 @@ describe("Bedrock Converse route", () => { const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) expect(response.text).toBe("Hello!") - const finishes = response.events.filter((event) => event.type === "request-finish") + const finishes = response.events.filter((event) => event.type === "finish") // Bedrock splits the finish across `messageStop` (carries reason) and // `metadata` (carries usage). We consolidate them into a single - // terminal `request-finish` event with both. + // terminal `finish` event with both. expect(finishes).toHaveLength(1) - expect(finishes[0]).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" }) expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, @@ -213,7 +213,7 @@ describe("Bedrock Converse route", () => { { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' }, ]) - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) }), ) @@ -294,8 +294,8 @@ describe("Bedrock Converse route", () => { model, system: [{ type: "text", text: "System prefix.", cache }], messages: [ - LLM.user([{ type: "text", text: "User prefix.", cache }]), - LLM.assistant([{ type: "text", text: "Assistant prefix.", cache }]), + Message.user([{ type: "text", text: "User prefix.", cache }]), + Message.assistant([{ type: "text", text: "Assistant prefix.", cache }]), ], generation: { maxTokens: 16, temperature: 0 }, }), @@ -335,7 +335,7 @@ describe("Bedrock Converse route", () => { id: "req_image", model, messages: [ - LLM.user([ + Message.user([ { type: "text", text: "What is in this image?" }, { type: "media", mediaType: "image/png", data: "AAAA" }, { type: "media", mediaType: "image/jpeg", data: "BBBB" }, @@ -371,7 +371,7 @@ describe("Bedrock Converse route", () => { LLM.request({ id: "req_image_bytes", model, - messages: [LLM.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])], + messages: [Message.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])], }), ) @@ -394,7 +394,7 @@ describe("Bedrock Converse route", () => { id: "req_doc", model, messages: [ - LLM.user([ + Message.user([ { type: "media", mediaType: "application/pdf", data: "PDFDATA", filename: "report.pdf" }, { type: "media", mediaType: "text/csv", data: "CSVDATA" }, ]), @@ -424,7 +424,7 @@ describe("Bedrock Converse route", () => { LLM.request({ id: "req_bad_image", model, - messages: [LLM.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])], + messages: [Message.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])], }), ).pipe(Effect.flip) @@ -438,7 +438,7 @@ describe("Bedrock Converse route", () => { LLM.request({ id: "req_bad_doc", model, - messages: [LLM.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])], + messages: [Message.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])], }), ).pipe(Effect.flip) @@ -471,9 +471,9 @@ describe("Bedrock Converse route", () => { model, tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], messages: [ - LLM.user("What's the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), + Message.user("What's the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), ], cache: "none", }), @@ -583,7 +583,7 @@ describe("Bedrock Converse recorded", () => { system: "Call tools exactly as requested.", prompt: "Call get_weather with city exactly Paris.", tools: [weatherTool], - toolChoice: LLM.toolChoice(weatherTool), + toolChoice: ToolChoice.make(weatherTool), cache: "none", generation: { maxTokens: 80, temperature: 0 }, }), diff --git a/packages/llm/test/provider/gemini-cache.recorded.test.ts b/packages/llm/test/provider/gemini-cache.recorded.test.ts index c3b3e55b3..b86980c43 100644 --- a/packages/llm/test/provider/gemini-cache.recorded.test.ts +++ b/packages/llm/test/provider/gemini-cache.recorded.test.ts @@ -29,9 +29,8 @@ const recorded = recordedTests({ provider: "google", protocol: "gemini", requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], - // Two identical requests in one cassette — match by recording order so the - // second call replays the cached-hit interaction. - options: { dispatch: "sequential" }, + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction. }) describe("Gemini cache recorded", () => { diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index e0b3864a2..7e6bbc846 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMError, Usage } from "../../src" +import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" import { LLMClient } from "../../src/route" import * as Gemini from "../../src/protocols/gemini" import { it } from "../lib/effect" @@ -49,12 +49,12 @@ describe("Gemini route", () => { ], toolChoice: { type: "tool", name: "lookup" }, messages: [ - LLM.user([ + Message.user([ { type: "text", text: "What is in this image?" }, { type: "media", mediaType: "image/png", data: "AAECAw==" }, ]), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], }), ) @@ -204,30 +204,37 @@ describe("Gemini route", () => { reasoningTokens: 1, totalTokens: 7, }) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 3, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 1, + totalTokens: 7, + providerMetadata: { + google: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, + }, + }) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, + { type: "reasoning-end", id: "reasoning-0" }, + { type: "text-end", id: "text-0" }, + { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "stop", - usage: new Usage({ - inputTokens: 5, - outputTokens: 3, - nonCachedInputTokens: 4, - cacheReadInputTokens: 1, - reasoningTokens: 1, - totalTokens: 7, - providerMetadata: { - google: { - promptTokenCount: 5, - candidatesTokenCount: 2, - totalTokenCount: 7, - thoughtsTokenCount: 1, - cachedContentTokenCount: 1, - }, - }, - }), + usage, }, ]) }), @@ -252,22 +259,41 @@ describe("Gemini route", () => { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }), ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + reasoningTokens: undefined, + totalTokens: 6, + providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, + }) expect(response.toolCalls).toEqual([ - { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { + type: "tool-call", + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, ]) expect(response.events).toEqual([ - { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { type: "step-start", index: 0 }, { - type: "request-finish", + type: "tool-call", + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, + { + type: "finish", reason: "tool-calls", - usage: new Usage({ - inputTokens: 5, - outputTokens: 1, - nonCachedInputTokens: 5, - totalTokens: 6, - providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, - }), + usage, }, ]) }), @@ -299,7 +325,7 @@ describe("Gemini route", () => { { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, ]) - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" }) }), ) @@ -318,8 +344,10 @@ describe("Gemini route", () => { ), ) - expect(length.events).toEqual([{ type: "request-finish", reason: "length" }]) - expect(filtered.events).toEqual([{ type: "request-finish", reason: "content-filter" }]) + expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) + expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" }) + expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"]) + expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" }) }), ) @@ -353,7 +381,7 @@ describe("Gemini route", () => { LLM.request({ id: "req_media", model, - messages: [LLM.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })], }), ).pipe(Effect.flip) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 2c692dcd7..4303a69ff 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Usage } from "../../src" +import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -149,9 +149,9 @@ describe("OpenAI Chat route", () => { id: "req_tool_result", model, messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], }), ) @@ -185,7 +185,7 @@ describe("OpenAI Chat route", () => { LLM.request({ id: "req_media", model, - messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], }), ).pipe(Effect.flip) @@ -199,7 +199,7 @@ describe("OpenAI Chat route", () => { LLM.request({ id: "req_reasoning", model, - messages: [LLM.assistant({ type: "reasoning", text: "hidden" })], + messages: [Message.assistant({ type: "reasoning", text: "hidden" })], }), ).pipe(Effect.flip) @@ -222,31 +222,36 @@ describe("OpenAI Chat route", () => { }), ) const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }) expect(response.text).toBe("Hello!") expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, + { type: "text-end", id: "text-0" }, + { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "stop", - usage: new Usage({ - inputTokens: 5, - outputTokens: 2, - nonCachedInputTokens: 4, - cacheReadInputTokens: 1, - reasoningTokens: 0, - totalTokens: 7, - providerMetadata: { - openai: { - prompt_tokens: 5, - completion_tokens: 2, - total_tokens: 7, - prompt_tokens_details: { cached_tokens: 1 }, - completion_tokens_details: { reasoning_tokens: 0 }, - }, - }, - }), + usage, }, ]) }), @@ -269,10 +274,21 @@ describe("OpenAI Chat route", () => { ).pipe(Effect.provide(fixedResponse(body))) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, - { type: "request-finish", reason: "tool-calls", usage: undefined }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined }, + { type: "finish", reason: "tool-calls", usage: undefined }, ]) }), ) @@ -293,6 +309,8 @@ describe("OpenAI Chat route", () => { ).pipe(Effect.provide(fixedResponse(body))) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, ]) @@ -352,7 +370,7 @@ describe("OpenAI Chat route", () => { const events = Array.from( yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))), ) - expect(events.map((event) => event.type)).toEqual(["text-delta"]) + expect(events.map((event) => event.type)).toEqual(["step-start"]) }), ) }) diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts index 627e6ef4a..50aac4109 100644 --- a/packages/llm/test/provider/openai-compatible-chat.test.ts +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM } from "../../src" +import { LLM, Message, ToolCallPart } from "../../src" import { LLMClient } from "../../src/route" import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" @@ -157,9 +157,9 @@ describe("OpenAI-compatible Chat route", () => { ], toolChoice: "lookup", messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], }), ) @@ -231,7 +231,7 @@ describe("OpenAI-compatible Chat route", () => { expect(response.text).toBe("Hello!") expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 }) - expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) }), ) }) diff --git a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts index 5a38898c0..2b67a0a4f 100644 --- a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts +++ b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts @@ -29,9 +29,9 @@ const recorded = recordedTests({ provider: "openai", protocol: "openai-responses", requires: ["OPENAI_API_KEY"], - // Two identical requests in one cassette — match by recording order so the - // second call replays the cached-hit interaction, not the cold-miss one. - options: { dispatch: "sequential" }, + // Two identical requests in one cassette — replay walks the cassette in + // recording order so the second call replays the cached-hit interaction, + // not the cold-miss one. }) describe("OpenAI Responses cache recorded", () => { diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 2319857ed..63452f61b 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Usage } from "../../src" +import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" @@ -251,9 +251,9 @@ describe("OpenAI Responses route", () => { id: "req_tool_result", model, messages: [ - LLM.user("What is the weather?"), - LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), - LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], }), ) @@ -333,32 +333,43 @@ describe("OpenAI Responses route", () => { }, ) const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }) expect(response.text).toBe("Hello!") expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "msg_1" }, { type: "text-delta", id: "msg_1", text: "Hello" }, { type: "text-delta", id: "msg_1", text: "!" }, + { type: "text-end", id: "msg_1" }, { - type: "request-finish", + type: "step-finish", + index: 0, reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, - usage: new Usage({ - inputTokens: 5, - outputTokens: 2, - nonCachedInputTokens: 4, - cacheReadInputTokens: 1, - reasoningTokens: 0, - totalTokens: 7, - providerMetadata: { - openai: { - input_tokens: 5, - output_tokens: 2, - total_tokens: 7, - input_tokens_details: { cached_tokens: 1 }, - output_tokens_details: { reasoning_tokens: 0 }, - }, - }, - }), + usage, + }, + { + type: "finish", + reason: "stop", + providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, + usage, }, ]) }), @@ -390,8 +401,24 @@ describe("OpenAI Responses route", () => { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }), ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + reasoningTokens: undefined, + totalTokens: 6, + providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, + }) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { + type: "tool-input-start", + id: "call_1", + name: "lookup", + providerMetadata: { openai: { itemId: "item_1" } }, + }, { type: "tool-input-delta", id: "call_1", @@ -404,23 +431,26 @@ describe("OpenAI Responses route", () => { name: "lookup", text: ':"weather"}', }, + { + type: "tool-input-end", + id: "call_1", + name: "lookup", + providerMetadata: { openai: { itemId: "item_1" } }, + }, { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, + providerExecuted: undefined, providerMetadata: { openai: { itemId: "item_1" } }, }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { - type: "request-finish", + type: "finish", reason: "tool-calls", - usage: new Usage({ - inputTokens: 5, - outputTokens: 1, - nonCachedInputTokens: 5, - totalTokens: 6, - providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, - }), + providerMetadata: undefined, + usage, }, ]) }), @@ -508,7 +538,7 @@ describe("OpenAI Responses route", () => { LLM.request({ id: "req_media", model, - messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], }), ).pipe(Effect.flip) diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 127a444a1..3af7a7760 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM, LLMEvent, LLMResponse, type LLMRequest, type ModelRef } from "../src" +import { LLM, LLMEvent, LLMResponse, ToolChoice, ToolDefinition, type LLMRequest, type ModelRef } from "../src" import { LLMClient } from "../src/route" import { tool } from "../src/tool" @@ -18,7 +18,7 @@ export const LARGE_CACHEABLE_SYSTEM = (() => { return sentence.repeat(250) })() -export const weatherTool = LLM.toolDefinition({ +export const weatherTool = ToolDefinition.make({ name: weatherToolName, description: "Get current weather for a city.", inputSchema: { @@ -70,7 +70,7 @@ export const weatherToolRequest = (input: { system: "Call tools exactly as requested.", prompt: "Call get_weather with city exactly Paris.", tools: [weatherTool], - toolChoice: LLM.toolChoice(weatherTool), + toolChoice: ToolChoice.make(weatherTool), cache: "none", generation: input.temperature === false @@ -120,8 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) => export const expectFinish = ( events: ReadonlyArray, - reason: Extract["reason"], -) => expect(events.at(-1)).toMatchObject({ type: "request-finish", reason }) + reason: Extract["reason"], +) => expect(events.at(-1)).toMatchObject({ type: "finish", reason }) export const expectWeatherToolCall = (response: LLMResponse) => expect(response.toolCalls).toMatchObject([ @@ -129,10 +129,12 @@ export const expectWeatherToolCall = (response: LLMResponse) => ]) export const expectWeatherToolLoop = (events: ReadonlyArray) => { - const finishes = events.filter(LLMEvent.is.requestFinish) - expect(finishes).toHaveLength(2) - expect(finishes[0]?.reason).toBe("tool-calls") - expect(finishes.at(-1)?.reason).toBe("stop") + const finishes = events.filter(LLMEvent.is.finish) + expect(finishes).toHaveLength(1) + expect(finishes[0]?.reason).toBe("stop") + + const stepFinishes = events.filter(LLMEvent.is.stepFinish) + expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"]) const toolCalls = events.filter(LLMEvent.is.toolCall) expect(toolCalls).toHaveLength(1) @@ -272,7 +274,7 @@ export const eventSummary = (events: ReadonlyArray) => { summary.push({ type: "tool-error", name: event.name, message: event.message }) continue } - if (event.type === "request-finish") { + if (event.type === "finish") { summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) }) } } diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 23bd9fd9b..01d6fadd9 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -44,6 +44,11 @@ describe("llm schema", () => { expect(() => Schema.decodeUnknownSync(LLMEvent)({ type: "bogus" })).toThrow() }) + test("finish constructors accept usage input", () => { + expect(LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 1 } }).usage).toBeInstanceOf(Usage) + expect(LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage) + }) + test("content part tagged union exposes guards", () => { expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true) expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false) diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 7251dee8a..573021c4c 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -1,10 +1,11 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM, LLMEvent, LLMRequest, LLMResponse } from "../src" +import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice } from "../src" import { LLMClient } from "../src/route" import * as AnthropicMessages from "../src/protocols/anthropic-messages" import * as OpenAIChat from "../src/protocols/openai-chat" -import { tool, ToolFailure } from "../src/tool" +import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool" +import { ToolRuntime } from "../src/tool-runtime" import { it } from "./lib/effect" import * as TestToolRuntime from "./lib/tool-runtime" import { dynamicResponse, scriptedResponses } from "./lib/http" @@ -78,8 +79,8 @@ describe("LLMClient tools", () => { yield* TestToolRuntime.runTools({ request: LLMRequest.update(baseRequest, { - generation: LLM.generation({ maxTokens: 50 }), - toolChoice: LLM.toolChoice("auto"), + generation: GenerationOptions.make({ maxTokens: 50 }), + toolChoice: ToolChoice.make("auto"), }), tools: { get_weather }, }).pipe(Stream.runCollect, Effect.provide(layer)) @@ -129,7 +130,7 @@ describe("LLMClient tools", () => { name: "get_weather", result: { type: "json", value: { temperature: 22, condition: "sunny" } }, }) - expect(events.at(-1)?.type).toBe("request-finish") + expect(events.at(-1)?.type).toBe("finish") expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.") }), ) @@ -148,11 +149,40 @@ describe("LLMClient tools", () => { ), ) - expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) }), ) + it.effect("passes tool call context to execute", () => + Effect.gen(function* () { + let context: ToolExecuteContext | undefined + const contextual = tool({ + description: "Capture tool context.", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.Struct({ ok: Schema.Boolean }), + execute: (_params, ctx) => + Effect.sync(() => { + context = ctx + return { ok: true } + }), + }) + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe( + Stream.runCollect, + Effect.provide( + scriptedResponses([ + sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")), + ]), + ), + ), + ) + + expect(events.some(LLMEvent.is.toolResult)).toBe(true) + expect(context).toEqual({ id: "call_ctx", name: "contextual" }) + }), + ) + it.effect("can expose tool schemas without executing tool calls", () => Effect.gen(function* () { const layer = scriptedResponses([ @@ -313,7 +343,14 @@ describe("LLMClient tools", () => { ), ) - expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(events.map((event) => event.type)).toEqual([ + "step-start", + "text-start", + "text-delta", + "text-end", + "step-finish", + "finish", + ]) expect(LLMResponse.text({ events })).toBe("Done.") }), ) @@ -336,7 +373,57 @@ describe("LLMClient tools", () => { ), ) - expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1]) + expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1]) + }), + ) + + it.effect("emits one final finish with aggregate usage", () => + Effect.gen(function* () { + let calls = 0 + const events = Array.from( + yield* ToolRuntime.stream({ + request: baseRequest, + tools: { get_weather }, + stopWhen: ToolRuntime.stepCountIs(2), + stream: () => + Stream.fromIterable( + calls++ === 0 + ? [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call_1", name: "get_weather", input: { city: "Paris" } }), + LLMEvent.stepFinish({ + index: 0, + reason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + }), + LLMEvent.finish({ + reason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + }), + ] + : [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textDelta({ id: "text_1", text: "Done." }), + LLMEvent.stepFinish({ + index: 0, + reason: "stop", + usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 }, + }), + LLMEvent.finish({ reason: "stop", usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 } }), + ], + ), + }).pipe(Stream.runCollect), + ) + + expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1]) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) + expect(events.find(LLMEvent.is.finish)?.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 7, + totalTokens: 12, + }) }), ) @@ -355,7 +442,7 @@ describe("LLMClient tools", () => { }).pipe(Stream.runCollect, Effect.provide(layer)), ) - expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.filter(LLMEvent.is.finish)).toHaveLength(1) expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) }), ) diff --git a/packages/llm/test/tool-stream.test.ts b/packages/llm/test/tool-stream.test.ts index 04a0035c9..b005d2666 100644 --- a/packages/llm/test/tool-stream.test.ts +++ b/packages/llm/test/tool-stream.test.ts @@ -21,11 +21,17 @@ describe("ToolStream", () => { if (ToolStream.isError(second)) return yield* second const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0) - expect(first.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }) - expect(second.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }) + expect(first.events).toEqual([ + { type: "tool-input-start", id: "call_1", name: "lookup" }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + ]) + expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }]) expect(finished).toEqual({ tools: {}, - event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ], }) }), ) @@ -50,7 +56,10 @@ describe("ToolStream", () => { expect(finished).toEqual({ tools: {}, - event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } }, + events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } }, + ], }) }), ) @@ -73,7 +82,9 @@ describe("ToolStream", () => { expect(finished).toEqual({ tools: {}, events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, { type: "tool-call", id: "call_1", name: "lookup", input: {} }, + { type: "tool-input-end", id: "call_2", name: "web_search" }, { type: "tool-call", id: "call_2", diff --git a/packages/opencode/migration/20260510033149_session_usage/migration.sql b/packages/opencode/migration/20260510033149_session_usage/migration.sql new file mode 100644 index 000000000..68e12aad0 --- /dev/null +++ b/packages/opencode/migration/20260510033149_session_usage/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE `session` ADD `cost` real DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_input` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_output` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_reasoning` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_cache_read` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `session` ADD `tokens_cache_write` integer DEFAULT 0 NOT NULL; diff --git a/packages/opencode/migration/20260510033149_session_usage/snapshot.json b/packages/opencode/migration/20260510033149_session_usage/snapshot.json new file mode 100644 index 000000000..ce5e56f48 --- /dev/null +++ b/packages/opencode/migration/20260510033149_session_usage/snapshot.json @@ -0,0 +1,1519 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "be5eae31-b7f8-4292-8827-c36a524abd1b", + "prevIds": ["630a93f2-c6c6-4191-a351-868d8f3a05d4"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e9b811fc5..b0427f231 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -108,6 +108,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -129,7 +130,6 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", - "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", diff --git a/packages/opencode/parsers-config.ts b/packages/opencode/parsers-config.ts index b4951afa2..9bcb9e85c 100644 --- a/packages/opencode/parsers-config.ts +++ b/packages/opencode/parsers-config.ts @@ -286,5 +286,15 @@ export default { ], }, }, + { + filetype: "diff", + aliases: ["udiff", "patch"], + wasm: "https://github.com/tree-sitter-grammars/tree-sitter-diff/releases/download/v0.1.0/tree-sitter-diff.wasm", + queries: { + highlights: [ + "https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-diff/master/queries/highlights.scm", + ], + }, + }, ], } diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index b1a587075..b34eaf7f0 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -1,63 +1,76 @@ #!/usr/bin/env bun -import { z } from "zod" import { Config } from "@/config/config" -import { TuiConfig } from "../src/cli/cmd/tui/config/tui" +import { Schema } from "effect" +import { TuiInfo } from "../src/cli/cmd/tui/config/tui-schema" -function generate(schema: z.ZodType) { - const result = z.toJSONSchema(schema, { - io: "input", // Generate input shape (treats optional().default() as not required) - /** - * We'll use the `default` values of the field as the only value in `examples`. - * This will ensure no docs are needed to be read, as the configuration is - * self-documenting. - * - * See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5 - */ - override(ctx) { - const schema = ctx.jsonSchema +type JsonSchema = Record +const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model" - // Preserve strictness: set additionalProperties: false for objects - if ( - schema && - typeof schema === "object" && - schema.type === "object" && - schema.additionalProperties === undefined - ) { - schema.additionalProperties = false - } +function generateEffect(schema: Schema.Top) { + const document = Schema.toJsonSchemaDocument(schema) + const normalized = normalize({ + $schema: "https://json-schema.org/draft/2020-12/schema", + ...document.schema, + $defs: document.definitions, + }) + if (!isRecord(normalized)) throw new Error("schema generator produced a non-object schema") + const restored = restoreModelRefs(normalized) + if (!isRecord(restored)) throw new Error("schema generator produced a non-object schema") + restored.allowComments = true + restored.allowTrailingCommas = true + return restored +} - // Add examples and default descriptions for string fields with defaults - if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) { - if (!schema.examples) { - schema.examples = [schema.default] - } +function normalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalize) + if (!isRecord(value)) return value - schema.description = [schema.description || "", `default: \`${String(schema.default)}\``] - .filter(Boolean) - .join("\n\n") - .trim() - } - }, - }) as Record & { - allowComments?: boolean - allowTrailingCommas?: boolean + const schema = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalize(item)])) + + if (Array.isArray(schema.anyOf)) { + const anyOf = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null") + if (anyOf.length !== schema.anyOf.length) { + const { anyOf: _, ...rest } = schema + if (anyOf.length === 1 && isRecord(anyOf[0])) return normalize({ ...anyOf[0], ...rest }) + return { ...rest, anyOf } + } } - // used for json lsps since config supports jsonc - result.allowComments = true - result.allowTrailingCommas = true + if (Array.isArray(schema.allOf) && schema.allOf.length === 1 && isRecord(schema.allOf[0])) { + const { allOf: _, ...rest } = schema + return normalize({ ...schema.allOf[0], ...rest }) + } - return result + if (schema.type === "integer" && schema.maximum === undefined) { + return { ...schema, maximum: Number.MAX_SAFE_INTEGER } + } + + return schema +} + +function restoreModelRefs(value: unknown, key?: string): unknown { + if (Array.isArray(value)) return value.map((item) => restoreModelRefs(item)) + if (!isRecord(value)) return value + + const schema = Object.fromEntries(Object.entries(value).map(([name, item]) => [name, restoreModelRefs(item, name)])) + if ((key === "model" || key === "small_model") && schema.type === "string") { + return { ...schema, $ref: MODEL_REF } + } + return schema +} + +function isRecord(value: unknown): value is JsonSchema { + return typeof value === "object" && value !== null && !Array.isArray(value) } const configFile = process.argv[2] const tuiFile = process.argv[3] console.log(configFile) -await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2)) +await Bun.write(configFile, JSON.stringify(generateEffect(Config.Info), null, 2)) if (tuiFile) { console.log(tuiFile) - await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.JsonSchemaInfo), null, 2)) + await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiInfo), null, 2)) } diff --git a/packages/opencode/specs/effect/errors.md b/packages/opencode/specs/effect/errors.md index 746e65869..e19199ef4 100644 --- a/packages/opencode/specs/effect/errors.md +++ b/packages/opencode/specs/effect/errors.md @@ -23,8 +23,9 @@ contracts. - Some services already use `Schema.TaggedErrorClass`, for example `Account`, `Auth`, `Permission`, `Question`, `Installation`, and parts of `Workspace`. -- Legacy Hono error handling recognizes `NamedError`, `Session.BusyError`, and a - few name-based cases, then emits the legacy `{ name, data }` JSON body. +- The temporary HttpApi compatibility middleware recognizes `NamedError`, + `Session.BusyError`, and a few name-based cases, then emits the legacy + `{ name, data }` JSON body. - Effect `HttpApi` only knows how to encode errors that are declared on the endpoint, group, or middleware. Undeclared expected errors become defects and eventually fall through to generic HTTP handling. @@ -127,7 +128,7 @@ Create an HttpApi-local error module, likely That module should provide: - Legacy-compatible public schemas for `{ name, data }` error bodies that must - remain SDK-compatible during the Hono migration. + remain SDK-compatible while route groups declare typed errors. - Small constructors or mapping helpers for common API errors such as not found, bad request, conflict, and unknown internal errors. - Route-group-specific adapters only when they encode domain-specific public @@ -173,7 +174,7 @@ Add the `httpapi/errors.ts` module before converting route groups. - Define a legacy `{ name, data }` body helper for SDK-compatible errors. - Define `UnknownError` for generic internal failures with a safe public message. - Define `BadRequestError` and `NotFoundError` equivalents only if the actual - wire body must match the legacy Hono SDK surface. + wire body must match the existing SDK surface. - Put the HTTP status on the public schema with `HttpApiSchema.status(...)` or `{ httpApiStatus: code }`; do not keep a separate name-to-status table. - Keep conversion helpers pure and small. They should not inspect `Cause` or @@ -238,7 +239,7 @@ Suggested route order: 2. `experimental` worktree mutations. 3. `provider` auth and model selection errors. 4. `mcp` OAuth and connection errors. -5. Remaining route groups as Hono deletion work progresses. +5. Remaining route groups as typed error contracts are declared. ### 6. Remove Defect Recovery @@ -286,8 +287,8 @@ For HttpApi conversions: errors. - Add a regression test that the temporary middleware is no longer needed for the migrated route. -- Keep bridge/parity tests aligned with legacy Hono behavior until Hono is - deleted or the SDK contract intentionally changes. +- Keep compatibility tests aligned with the existing SDK contract until the + public error shape intentionally changes. ## Verification Commands diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 01af9da6c..13838e833 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -57,17 +57,9 @@ Rules: - Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase - No `Layer.fresh` for normal per-directory isolation; use `InstanceState` -## Schema → Zod interop +## Schema boundaries -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: - -```ts -import { zod } from "@opencode-ai/core/effect-zod" - -export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union -``` - -See `Auth.ZodInfo` for the canonical example. +Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion. ## InstanceState init patterns diff --git a/packages/opencode/specs/effect/routes.md b/packages/opencode/specs/effect/routes.md index 3bf7e1b55..8066bda34 100644 --- a/packages/opencode/specs/effect/routes.md +++ b/packages/opencode/specs/effect/routes.md @@ -39,26 +39,19 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu ## Current route files -Current instance route files live under `src/server/routes/instance`. - -Files that are already mostly on the intended service-yielding shape: - -- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service` -- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service` -- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service` -- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service` -- [x] `server/routes/instance/pty.ts` — handlers yield `Pty.Service` +Current instance route files live under `src/server/routes/instance/httpapi`. +Most handlers already yield stable services at route-layer construction and then +close over those services in endpoint implementations. Files still worth tracking here: -- [ ] `server/routes/instance/session.ts` — still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage -- [ ] `server/routes/instance/index.ts` — mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path` -- [ ] `server/routes/instance/file.ts` — most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed -- [ ] `server/routes/instance/experimental.ts` — mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads -- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)` -- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style +- [ ] `handlers/session.ts` — still the heaviest mixed file; some paths keep compatibility translations and direct event publication +- [ ] `handlers/experimental.ts` — mixed state; some handlers still rely on request-local context reads +- [ ] `middleware/*` — still contains compatibility policy for auth, compression, errors, instance context, and workspace routing +- [ ] `public.ts` — still owns SDK/OpenAPI compatibility translation shims +- [ ] raw route modules — WebSocket and catch-all routes should stay explicit and avoid rebuilding stable layers per request ## Notes -- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files. -- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files. +- Route conversion is now less about backend migration and more about removing the remaining direct `Instance.*` reads, request-local service plumbing, and OpenAPI compatibility shims. +- Prefer route-layer service capture over rebuilding or providing stable layers inside individual handlers. diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index e20605c3b..1fc6a4478 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -1,19 +1,16 @@ # Schema migration Practical reference for migrating data types in `packages/opencode` from -Zod-first definitions to Effect Schema with Zod compatibility shims. +Zod-first definitions to Effect Schema. ## Goal Use Effect Schema as the source of truth for domain models, IDs, inputs, -outputs, and typed errors. Keep Zod available at existing HTTP, tool, and -compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@opencode-ai/core/effect-zod`. +outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and +native JSON Schema generation at HTTP, tool, and AI SDK boundaries. -The long-term driver is `specs/effect/http-api.md` — once the HTTP server -moves to `@effect/platform`, every Schema-first DTO can flow through -`HttpApi` / `HttpRouter` without a zod translation layer, and the entire -`effect-zod` walker plus every `.zod` static can be deleted. +The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should +flow through `HttpApi` / `HttpRouter` without a Zod translation layer. ## Preferred shapes @@ -26,19 +23,16 @@ export class Info extends Schema.Class("Foo.Info")({ id: FooID, name: Schema.String, enabled: Schema.Boolean, -}) { - static readonly zod = zod(Info) -} +}) {} ``` -If the class cannot reference itself cleanly during initialization, use the -two-step `withStatics` pattern: +If a schema needs local static helpers, use the two-step `withStatics` pattern: ```ts export const Info = Schema.Struct({ id: FooID, name: Schema.String, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) }))) ``` ### Errors @@ -53,15 +47,13 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Foo ### IDs and branded leaf types -Keep branded/schema-backed IDs as Effect schemas and expose -`static readonly zod` for compatibility when callers still expect Zod. +Keep branded/schema-backed IDs as Effect schemas. ### Refinements -Reuse named refinements instead of re-spelling `z.number().int().positive()` -in every schema. The `effect-zod` walker translates the Effect versions into -the corresponding zod methods, so JSON Schema output (`type: integer`, -`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved. +Reuse named refinements instead of re-spelling numeric or string constraints in +every schema. Boundary JSON Schema helpers should normalize native Effect JSON +Schema output only where a provider requires it. ```ts const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) @@ -69,18 +61,15 @@ const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreate const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) ``` -See `test/util/effect-zod.test.ts` for the full set of translated checks. - ## Compatibility rule -During migration, route validators, tool parameters, and any existing -Zod-based boundary should consume the derived `.zod` schema instead of +During migration, route validators, tool parameters, and AI SDK schemas should +consume Effect schemas directly or use a narrow boundary helper. Avoid maintaining a second hand-written Zod schema. The default should be: - Effect Schema owns the type -- `.zod` exists only as a compatibility surface - new domain models should not start Zod-first unless there is a concrete boundary-specific need @@ -89,27 +78,22 @@ The default should be: It is fine to keep a Zod-native schema temporarily when: - the type is only used at an HTTP or tool boundary and is not reused elsewhere -- the validator depends on Zod-only transforms or behavior not yet covered by `zod()` +- the validator is part of an existing public API that explicitly accepts Zod - the migration would force unrelated churn across a large call graph When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth. -## Escape hatches +## Boundary helpers -The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for -cases the pure-Schema path cannot express. Each one stays in the codebase -only as long as its upstream or local dependency requires it — inline -comments document when each can be deleted. +Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge. -### `ZodOverride` annotation +- Tool parameters: `ToolJsonSchema.fromSchema(...)` and `ToolJsonSchema.fromTool(...)` +- Public config/TUI schemas: `packages/opencode/script/schema.ts` +- AI SDK object generation: `Schema.toStandardSchemaV1(...)` plus `Schema.toStandardJSONSchemaV1(...)` -Replaces the entire derivation with a hand-crafted zod schema. Used when: - -- the target carries external `$ref` metadata (e.g. - `config/model-id.ts` points at `https://models.dev/...`) -- the target is a zod-only schema that cannot yet be expressed as Schema - (e.g. `ConfigAgent.Info`, `Log.Level`) +Plugin tools are the main remaining intentional Zod boundary because the public +plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`. ### Local `DeepMutable` in `config/config.ts` @@ -133,7 +117,7 @@ Migrate in this order: 2. Exported `Info`, `Input`, `Output`, and DTO types 3. Tagged domain errors 4. Service-local internal models -5. Route and tool boundary validators that can switch to `.zod` +5. Route and tool boundary validators that can switch to native Effect Schema helpers This keeps shared types canonical first and makes boundary updates mostly mechanical. @@ -142,21 +126,18 @@ mechanical. ### `src/config/` ✅ complete -All of `packages/opencode/src/config/` has been migrated. Files that still -import `z` do so only for local `ZodOverride` bridges or for `z.ZodType` -type annotations — the `export const ` values are all Effect -Schema at source. +All of `packages/opencode/src/config/` has been migrated. The `export const +` values are all Effect Schema at source. A file is considered "done" when: - its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.) are authored as Effect Schema -- any remaining zod is either a derived compat bridge (via `zod()` / - `zodObject()`), a `z.ZodType` type annotation, or a documented - `ZodOverride` escape hatch — never a hand-written parallel source of truth +- any remaining Zod is an explicit boundary compatibility choice, not a + hand-written parallel source of truth -Files that meet this bar but still carry a compat bridge are checked off -with an inline note describing the bridge and what unblocks its removal. +Files that meet this bar but still carry a compatibility boundary are checked +off with an inline note describing the boundary and what unblocks its removal. - [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider - [x] server, layout @@ -305,38 +286,18 @@ emitted JSON Schema must stay byte-identical. ### HTTP route boundaries -Every file in `src/server/routes/` uses hono-openapi with zod validators for -route inputs/outputs. Migrating these individually is the last step; most -will switch to `.zod` derived from the Schema-migrated domain types above, -which means touching them is largely mechanical once the domain side is -done. +The server route tree now lives under `src/server/routes/instance/httpapi` and +uses Effect HttpApi contracts for request and response schemas. Remaining schema +work is no longer a Hono route migration; it is compatibility cleanup around +derived `.zod` statics, OpenAPI translation shims, and route groups that still +need explicit SDK-visible error contracts. -- [ ] `src/server/error.ts` -- [x] `src/server/event.ts` -- [x] `src/server/projectors.ts` -- [ ] `src/server/routes/control/index.ts` -- [ ] `src/server/routes/control/workspace.ts` -- [ ] `src/server/routes/global.ts` -- [ ] `src/server/routes/instance/index.ts` -- [ ] `src/server/routes/instance/config.ts` -- [ ] `src/server/routes/instance/event.ts` -- [ ] `src/server/routes/instance/experimental.ts` -- [ ] `src/server/routes/instance/file.ts` -- [ ] `src/server/routes/instance/mcp.ts` -- [ ] `src/server/routes/instance/permission.ts` -- [ ] `src/server/routes/instance/project.ts` -- [ ] `src/server/routes/instance/provider.ts` -- [ ] `src/server/routes/instance/pty.ts` -- [ ] `src/server/routes/instance/question.ts` -- [ ] `src/server/routes/instance/session.ts` -- [ ] `src/server/routes/instance/sync.ts` -- [ ] `src/server/routes/instance/tui.ts` +Good follow-up targets: -The bigger prize for this group is the `@effect/platform` HTTP migration -described in `specs/effect/http-api.md`. Once that lands, every one of -these files changes shape entirely (`HttpApi.endpoint(...)` and friends), -so the Schema-first domain types become a prerequisite rather than a -sibling task. +- shrink `public.ts` legacy OpenAPI translation shims one SDK-compatible slice at a time +- replace production `.zod.safeParse(...)` call sites with Effect Schema decoders +- remove derived `.zod` statics after their production consumers are gone +- declare route-group errors directly instead of relying on compatibility middleware ### Everything else @@ -381,15 +342,8 @@ piecewise. - [ ] `src/util/update-schema.ts` - [ ] `src/worktree/index.ts` -### Do-not-migrate - -- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever - (it's what emits zod from Schema). Goes away only when the `.zod` - compatibility layer is no longer needed anywhere. - ## Notes -- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. - Keep the migration incremental. Converting the domain model first is more diff --git a/packages/opencode/specs/effect/server-package.md b/packages/opencode/specs/effect/server-package.md index 06e89c18d..036472337 100644 --- a/packages/opencode/specs/effect/server-package.md +++ b/packages/opencode/specs/effect/server-package.md @@ -1,668 +1,58 @@ -# Server package extraction +# Server Package Extraction -Practical reference for extracting a future `packages/server` from the current `packages/opencode` monolith while `packages/core` is still being migrated to Effect. +Practical reference for a future `packages/server` split after the opencode +server moved to the Effect HttpApi backend. -This document is intentionally execution-oriented. +## Current State -It should give an agent enough context to land one incremental PR at a time without needing to rediscover the package strategy, route migration rules, or current constraints. +- The server still lives in `packages/opencode`. +- The runtime and app layer are centralized in `src/effect/app-runtime.ts` and + `src/effect/run-service.ts`. +- The route tree lives under `src/server/routes/instance/httpapi` and is hosted + from `src/server/server.ts`. +- OpenAPI generation is based on the HttpApi contract plus compatibility + translation in `src/server/routes/instance/httpapi/public.ts`. +- There is no standalone `packages/server` workspace yet. -## Goal - -Create `packages/server` as the home for: - -- HTTP contract definitions -- HTTP handler implementations -- OpenAPI generation -- eventual embeddable server APIs for Node apps - -Do this without blocking on the full `packages/core` extraction. - -## Future state +## Future State Target package layout: -- `packages/core` - all opencode services, Effect-first source of truth -- `packages/server` - opencode server, with separate contract and implementation, still producing `openapi.json` -- `packages/cli` - TUI + CLI entrypoints -- `packages/sdk` - generated from the server OpenAPI spec, may add higher-level wrappers -- `packages/plugin` - generated or semi-hand-rolled non-Effect package built from core plugin definitions +- `packages/core` - shared domain services and schemas +- `packages/server` - HTTP contracts, handlers, OpenAPI generation, and an + embeddable server API +- `packages/cli` - TUI and CLI entrypoints +- `packages/sdk` - generated from the server OpenAPI spec +- `packages/plugin` - plugin authoring surface -Desired user stories: +## Extraction Rule -- import from `core` and build a custom agent or app-specific runtime -- import from `server` and embed the full opencode server into an existing Node app -- spawn the CLI and talk to the server through that boundary +Do not create a package cycle. -## Current state +Until enough shared service code lives outside `packages/opencode`, a future +`packages/server` should either: -Everything still lives in `packages/opencode`. +- own pure HttpApi contracts only, or +- accept host-provided services/layers/callbacks from `packages/opencode` -Important current facts: +It should not import `packages/opencode` services while `packages/opencode` +imports it to host routes. -- there is no `packages/core` or `packages/cli` workspace yet -- there is no `packages/server` workspace yet on this branch -- the main host server is still Hono-based in `src/server/server.ts` -- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts` -- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts` -- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*` -- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI` -- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes +## Suggested PR Sequence -This means the package split should start from an extraction path, not from greenfield package ownership. +1. Keep shrinking OpenAPI compatibility shims in `httpapi/public.ts`. +2. Move stable domain schemas into shared packages only when they no longer + depend on opencode-local runtime modules. +3. Extract pure HttpApi contract modules into `packages/server` once the contract + can compile without importing `packages/opencode` implementation details. +4. Extract handler factories after their service dependencies can be supplied by + a host layer instead of imported directly. +5. Move server hosting last, after package ownership is clear. -## Structural reference +## Non-Goals -Use `anomalyco/opentunnel` as the structural reference for `packages/server`. - -The important pattern there is: - -- `packages/core` owns services and domain schemas -- `packages/server/src/definition/*` owns pure `HttpApi` contracts -- `packages/server/src/api/*` owns `HttpApiBuilder.group(...)` implementations and server-side middleware wiring -- `packages/server/src/index.ts` becomes the composition root only after the server package really owns runtime hosting - -Relevant `opentunnel` files: - -- `packages/server/src/definition/index.ts` -- `packages/server/src/definition/tunnel.ts` -- `packages/server/src/api/index.ts` -- `packages/server/src/api/tunnel.ts` -- `packages/server/src/api/client.ts` -- `packages/server/src/index.ts` - -The intended direction here is the same, but the current `opencode` package split is earlier in the migration. - -That means: - -- we should follow the same `definition` and `api` naming -- we should keep contract and implementation as separate modules from the start -- we should postpone the runtime composition root until `packages/core` exists enough to support it cleanly - -## Key decision - -Start `packages/server` as a contract and implementation package only. - -Do not make it the runtime host yet. - -Why: - -- `packages/core` does not exist yet -- the current server host still lives in `packages/opencode` -- moving host ownership immediately would force a large package and runtime shuffle while Effect service extraction is still in flight -- if `packages/server` imports services from `packages/opencode` while `packages/opencode` imports `packages/server` to host routes, we create a package cycle immediately - -Short version: - -1. create `packages/server` -2. move pure `HttpApi` contracts there -3. move handler factories there -4. keep `packages/opencode` as the temporary Hono host -5. merge `packages/server` OpenAPI with the legacy Hono OpenAPI during the transition -6. move server hosting later, after `packages/core` exists enough - -## Dependency rule - -Phase 1 rule: - -- `packages/server` must not import from `packages/opencode` - -Allowed in phase 1: - -- `packages/opencode` imports `packages/server` -- `packages/server` accepts host-provided services, layers, or callbacks as inputs -- `packages/server` may temporarily own transport-local placeholder schemas when a canonical shared schema does not exist yet - -Future rule after `packages/core` exists: - -- `packages/server` imports from `packages/core` -- `packages/cli` imports from `packages/server` and `packages/core` -- `packages/opencode` shrinks or disappears as package responsibilities are fully split - -## HttpApi model - -Use Effect v4 `HttpApi` as the source of truth for migrated HTTP routes. - -Important properties from the current `effect` / `effect-smol` model: - -- `HttpApi`, `HttpApiGroup`, and `HttpApiEndpoint` are pure contract definitions -- handlers are implemented separately with `HttpApiBuilder.group(...)` -- OpenAPI can be generated from the contract alone -- auth and middleware can later be modeled with `HttpApiMiddleware.Service` -- SSE and websocket routes are not good first-wave `HttpApi` targets - -This package split should preserve that separation explicitly. - -Default shape for migrated routes: - -- contract lives in `packages/server/src/definition/*` -- implementation lives in `packages/server/src/api/*` -- host mounting stays outside for now - -## OpenAPI rule - -During the transition there is still one spec artifact. - -Default rule: - -- `packages/server` generates OpenAPI from `HttpApi` contract -- `packages/opencode` keeps generating legacy OpenAPI from Hono routes -- the temporary exported server spec is a merged document -- `packages/sdk` continues consuming one `openapi.json` - -Merge safety rules: - -- fail on duplicate `path + method` -- fail on duplicate `operationId` -- prefer explicit summary, description, and operation ids on all new `HttpApi` endpoints - -Practical implication: - -- do not make the SDK consume two specs -- do not switch SDK generation to `packages/server` only until enough of the route surface has moved - -## Package shape - -Minimum viable `packages/server`: - -- `src/index.ts` -- `src/definition/index.ts` -- `src/definition/api.ts` -- `src/definition/question.ts` -- `src/api/index.ts` -- `src/api/question.ts` -- `src/openapi.ts` -- `src/bridge/hono.ts` -- `src/types.ts` - -Later additions, once there is enough real contract surface: - -- `src/api/client.ts` -- runtime composition in `src/index.ts` - -Suggested initial exports: - -- `api` -- `openapi` -- `questionApi` -- `makeQuestionHandler` - -Phase 1 responsibilities: - -- own pure API contracts -- own handler factories for migrated slices -- own contract-generated OpenAPI -- expose host adapters needed by `packages/opencode` - -Phase 1 non-goals: - -- do not own `listen()` -- do not own adapter selection -- do not own global server middleware -- do not own websocket or SSE transport -- do not own process bootstrapping for CLI entrypoints - -## Current source inventory - -These files matter for the first phase. - -Current host and route composition: - -- `src/server/server.ts` -- `src/server/control/index.ts` -- `src/server/routes/instance/index.ts` -- `src/server/middleware.ts` -- `src/server/adapter.bun.ts` -- `src/server/adapter.node.ts` - -Current bridged `HttpApi` slices: - -- `src/server/routes/instance/httpapi/question.ts` -- `src/server/routes/instance/httpapi/permission.ts` -- `src/server/routes/instance/httpapi/provider.ts` -- `src/server/routes/instance/httpapi/config.ts` -- `src/server/routes/instance/httpapi/project.ts` -- `src/server/routes/instance/httpapi/server.ts` - -Current OpenAPI flow: - -- `src/server/server.ts` via `Server.openapi()` -- `src/cli/cmd/generate.ts` -- `packages/sdk/js/script/build.ts` - -Current runtime and service layer: - -- `src/effect/app-runtime.ts` -- `src/effect/run-service.ts` - -## Ownership rules - -Move first into `packages/server`: - -- the experimental `question` `HttpApi` slice -- future `provider` and `config` JSON read slices -- any new `HttpApi` route groups -- transport-local OpenAPI generation for migrated routes - -Keep in `packages/opencode` for now: - -- `src/server/server.ts` -- `src/server/control/index.ts` -- `src/server/routes/**/*.ts` -- `src/server/middleware.ts` -- `src/server/adapter.*.ts` -- `src/effect/app-runtime.ts` -- `src/effect/run-service.ts` -- all Effect services until they move to `packages/core` - -## Placeholder schema rule - -`packages/core` is allowed to lag behind. - -Until shared canonical schemas move to `packages/core`: - -- prefer importing existing Effect Schema DTOs from current locations when practical -- if a route only needs a transport-local type and moving the canonical schema would create unrelated churn, allow a temporary server-local placeholder schema -- if a placeholder is introduced, leave a short note so it does not become permanent - -The default rule from `schema.md` still applies: - -- Effect Schema owns the type -- `.zod` is compatibility only -- avoid parallel hand-written Zod and Effect definitions for the same migrated route shape - -## Host boundary rule - -Until host ownership moves: - -- auth stays at the outer Hono app level -- compression stays at the outer Hono app level -- CORS stays at the outer Hono app level -- instance and workspace lookup stay at the current middleware layer -- `packages/server` handlers should assume the host already provided the right request context -- do not redesign host middleware just to land the package split - -This matches the current guidance in `http-api.md`: - -- keep auth outside the first parallel `HttpApi` slices -- keep instance lookup outside the first parallel `HttpApi` slices -- keep the first migrations transport-focused and semantics-preserving - -## Route selection rules - -Good early migration targets: - -- `question` -- `provider` auth read endpoint -- `config` providers read endpoint -- small read-only instance routes - -Bad early migration targets: - -- `session` -- `event` -- `pty` -- most `global` streaming or process-heavy routes -- anything requiring websocket upgrade handling -- anything that mixes many mutations and streaming in one file - -## First vertical slice - -The first slice for the package split is still the existing `question` `HttpApi` group. - -Why `question` first: - -- it already exists as an experimental `HttpApi` slice -- it already follows the desired contract and implementation split in one file -- it is already mounted through the current Hono host -- it is JSON-only -- it has low blast radius - -Use the first slice to prove: - -- package boundary -- contract and implementation split -- host mounting from `packages/opencode` -- merged OpenAPI output -- test ergonomics for future slices - -Do not broaden scope in the first slice. - -## Incremental migration order - -Use small PRs. - -Each PR should be easy to review, easy to revert, and should not mix extraction work with unrelated service refactors. - -### PR 1. Create `packages/server` - -Scope: - -- add the new workspace package -- add package manifest and tsconfig -- add empty `src/index.ts`, `src/definition/api.ts`, `src/definition/index.ts`, `src/api/index.ts`, `src/openapi.ts`, and supporting scaffolding - -Rules: - -- no production behavior changes -- no host server changes yet -- no imports from `packages/opencode` inside `packages/server` -- prefer `opentunnel`-style naming from the start: `definition` for contracts, `api` for implementations - -Done means: - -- `packages/server` typechecks -- the workspace can import it -- the package boundary is in place for follow-up PRs - -### PR 2. Move the experimental question contract - -Scope: - -- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts` -- place it in `packages/server/src/definition/question.ts` -- aggregate it in `packages/server/src/definition/api.ts` -- generate OpenAPI in `packages/server/src/openapi.ts` - -Rules: - -- contract only in this PR -- no handler movement yet if that keeps the diff simpler -- keep operation ids and docs metadata stable - -Done means: - -- question contract lives in `packages/server` -- OpenAPI can be generated from contract alone -- no runtime behavior changes yet - -### PR 3. Move the experimental question handler factory - -Scope: - -- extract the question `HttpApiBuilder.group(...)` implementation into `packages/server/src/api/question.ts` -- expose it as a factory that accepts host-provided dependencies or wiring -- add a small Hono bridge in `packages/server/src/bridge/hono.ts` if needed - -Rules: - -- `packages/server` must still not import from `packages/opencode` -- handler code should stay thin and service-delegating -- do not redesign the question service itself in this PR - -Done means: - -- `packages/server` can produce the experimental question handler -- the package still stays cycle-free - -### PR 4. Mount `packages/server` question from `packages/opencode` - -Scope: - -- replace local experimental question route wiring in `packages/opencode` -- keep the same mount path: -- `/question` -- `/question/:requestID/reply` -- `/question/:requestID/reject` - -Rules: - -- no behavior change -- preserve existing docs path -- preserve current request and response shapes - -Done means: - -- existing question `HttpApi` test still passes -- runtime behavior is unchanged -- the current host server is now consuming `packages/server` - -### PR 5. Merge legacy and contract OpenAPI - -Scope: - -- keep `Server.openapi()` as the temporary spec entrypoint -- generate legacy Hono spec -- generate `packages/server` contract spec -- merge them into one document -- keep `cli/cmd/generate.ts` and `packages/sdk/js/script/build.ts` consuming one spec - -Rules: - -- fail loudly on duplicate `path + method` -- fail loudly on duplicate `operationId` -- do not silently overwrite one source with the other - -Done means: - -- one merged spec is produced -- migrated question paths can come from `packages/server` -- existing SDK generation path still works - -### PR 6. Add merged OpenAPI coverage - -Scope: - -- add one test for merged OpenAPI -- assert both a legacy Hono route and a migrated `HttpApi` route exist - -Rules: - -- test the merged document, not just the `packages/server` contract spec in isolation -- pick one stable legacy route and one stable migrated route - -Done means: - -- the merged-spec path is covered -- future route migrations have a guardrail - -### PR 7. Migrate `GET /provider/auth` - -Scope: - -- add `GET /provider/auth` as the next `HttpApi` slice in `packages/server` -- mount it in parallel from `packages/opencode` - -Why this route: - -- JSON-only -- simple service delegation -- small response shape -- already listed as the best next `provider` candidate in `http-api.md` - -Done means: - -- route works through the current host -- route appears in merged OpenAPI -- no semantic change to provider auth behavior - -### PR 8. Migrate `GET /config/providers` - -Scope: - -- add `GET /config/providers` as a `HttpApi` slice in `packages/server` -- mount it in parallel from `packages/opencode` - -Why this route: - -- JSON-only -- read-only -- low transport complexity -- already listed as the best next `config` candidate in `http-api.md` - -Done means: - -- route works unchanged -- route appears in merged OpenAPI - -### PR 9+. Migrate small read-only instance routes - -Candidate order: - -1. `GET /path` -2. `GET /vcs` -3. `GET /vcs/diff` -4. `GET /command` -5. `GET /agent` -6. `GET /skill` - -Rules: - -- one or two endpoints per PR -- prefer read-only routes first -- keep outer middleware unchanged -- keep business logic in the existing service layer - -Done means for each PR: - -- contract lives in `packages/server` -- handler lives in `packages/server` -- route is mounted from the current host -- route appears in merged OpenAPI -- behavior remains unchanged - -### Later PR. Move host ownership into `packages/server` - -Only start this after there is enough `packages/core` surface to depend on directly. - -Scope: - -- move server composition into `packages/server` -- add embeddable APIs such as `createServer(...)`, `listen(...)`, or `createApp(...)` -- move adapter selection and server startup out of `packages/opencode` - -Rules: - -- do not start this while `packages/server` still depends on `packages/opencode` -- do not mix this with route migration PRs - -Done means: - -- `packages/server` can be embedded in another Node app -- `packages/cli` can depend on `packages/server` -- host logic no longer lives in `packages/opencode` - -## PR sizing rule - -Every migration PR should satisfy all of these: - -- one route group or one to two endpoints -- no unrelated service refactor -- no auth redesign -- no middleware redesign -- OpenAPI updated -- at least one route test or spec test added or updated - -## Done means for a migrated route group - -A route group migration is complete only when: - -1. the `HttpApi` contract lives in `packages/server` -2. handler implementation lives in `packages/server` -3. the route is mounted from the current host in `packages/opencode` -4. the route appears in merged OpenAPI -5. request and response schemas are Effect Schema-first or clearly temporary placeholders -6. existing behavior remains unchanged -7. the route has straightforward test coverage - -## Validation expectations - -For package-split PRs, validate the smallest useful thing. - -Typical validation for the first waves: - -- `bun typecheck` in the touched package directory or directories -- the relevant server / route coverage for the migrated slice -- merged OpenAPI coverage if the PR touches spec generation - -Do not run tests from repo root. - -## Main risks - -### Package cycle - -This is the biggest risk. - -Bad state: - -- `packages/server` imports services or runtime from `packages/opencode` -- `packages/opencode` imports route definitions or handlers from `packages/server` - -Avoid by: - -- keeping phase-1 `packages/server` free of `packages/opencode` imports -- using factories and host-provided wiring instead of direct service imports - -### Spec drift - -During the transition there are two route-definition sources. - -Avoid by: - -- one merged spec -- collision checks -- explicit `operationId`s -- merged OpenAPI tests - -### Middleware mismatch - -Current auth, compression, CORS, and instance selection are Hono-centered. - -Avoid by: - -- leaving them where they are during the first wave -- not trying to solve `HttpApiMiddleware.Service` globally in the package-split PRs - -### Core lag - -`packages/core` will not be ready everywhere. - -Avoid by: - -- allowing small transport-local placeholder schemas where necessary -- keeping those placeholders clearly temporary -- not blocking the server extraction on full schema movement - -### Scope creep - -The first vertical slice is easy to overload. - -Avoid by: - -- proving the package boundary first -- not mixing package creation, route migration, host redesign, and core extraction in the same change - -## Non-goals for the first wave - -- do not replace all Hono routes at once -- do not migrate SSE or websocket routes first -- do not redesign auth -- do not redesign instance lookup -- do not wait for full `packages/core` before starting `packages/server` -- do not change SDK generation to consume multiple specs - -## Checklist - -- [x] create `packages/server` -- [x] add package-level exports for contract and OpenAPI -- [ ] extract `question` contract into `packages/server` -- [ ] extract `question` handler factory into `packages/server` -- [ ] mount `question` from `packages/opencode` -- [ ] merge legacy and contract OpenAPI into one document -- [ ] add merged-spec coverage -- [ ] migrate `GET /provider/auth` -- [ ] migrate `GET /config/providers` -- [ ] migrate small read-only instance routes one or two at a time -- [ ] move host ownership into `packages/server` only after `packages/core` is ready enough -- [ ] split `packages/cli` after server and core boundaries are stable - -## Rule of thumb - -The fastest correct path is: - -1. establish `packages/server` as the contract-first boundary -2. keep `packages/opencode` as the temporary host -3. migrate a few safe JSON routes -4. keep one merged OpenAPI document -5. move actual host ownership only after `packages/core` can support it cleanly - -If a proposed PR would make `packages/server` import from `packages/opencode`, stop and restructure the boundary first. +- Do not revive the old dual-backend migration shape. +- Do not split server hosting before service dependencies have a clean package + boundary. +- Do not switch SDK generation to a new package until generated output is known + to remain compatible. diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index 255c09644..5be155d1b 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -100,7 +100,7 @@ Verification: - Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. - Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. -- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. - Delete one path override only after generated OpenAPI is unchanged for that param. Concrete first targets: diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index c1a9b271c..e9ece6c0a 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -29,6 +29,16 @@ Example: "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]], "plugin_enabled": { "acme.demo": false + }, + "attention": { + "enabled": true, + "notifications": true, + "sound": true, + "volume": 0.4, + "sound_pack": "opencode.default", + "sounds": { + "error": "/Users/me/sounds/error.mp3" + } } } ``` @@ -45,6 +55,11 @@ Example: - Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id. - `plugin_enabled` is merged across config layers. - Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup. +- `attention.enabled` defaults to `false`; when `false`, it disables all `api.attention.notify(...)` delivery. +- `attention.notifications` and `attention.sound` independently control terminal-mediated desktop notifications and built-in sounds. +- `attention.volume` sets the default built-in sound volume from `0` to `1`. +- `attention.sound_pack` selects the initial semantic sound pack. Persisted runtime selection in KV can override it. +- `attention.sounds` overrides individual semantic sound slots such as `error`, `done`, or `subagent_done`. - `leader_timeout` is a top-level TUI setting. - `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects). - `keybinds.leader` sets the key used by `` shortcuts. @@ -212,6 +227,7 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug Top-level API groups exposed to `tui(api, options, meta)`: - `api.app.version` +- `api.attention.notify(input)` - `api.keys.formatSequence(parts)`, `formatBindings(bindings)` - `api.keymap` - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` @@ -246,6 +262,24 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show. - For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`. +### Attention + +- `api.attention.notify({ title?, message, notification?, sound? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host. +- `message` is required; `title` defaults to `"opencode"`; `notification` defaults to enabled with `when: "blurred"`; `sound` defaults to enabled with `when: "always"`. +- `when: "always"` requests delivery regardless of terminal focus state. +- `when: "focused"` only requests delivery after the terminal is known focused; `when: "blurred"` only requests delivery after the terminal is known blurred. +- Example: `notification: { when: "blurred" }, sound: { name: "question", when: "always" }` plays sound while focused but only triggers system notifications when blurred. +- Semantic sound names are `"default"`, `"question"`, `"permission"`, `"error"`, `"done"`, and `"subagent_done"`. +- `sound: true` plays the `"default"` sound; `sound: { name: "question" }` plays a named semantic sound. +- `sound: { volume }` overrides volume for that call; `sound: false` disables sound for that call; `notification: false` disables system notification for that call. +- `api.attention.soundboard.registerPack({ id, name?, sounds })` registers a sound pack and returns a disposer. Relative paths resolve from the plugin root and are cleaned up on plugin deactivation. +- `api.attention.soundboard.activate(id, { persist })` selects the active pack. `persist: true` writes the selected pack id to TUI KV state, not `tui.json`. +- `api.attention.soundboard.current()` and `list()` expose the active/registered packs for plugin UX. +- Config `attention.sounds` overrides active-pack sounds by slot. Failed loads fall back to the active pack and then `opencode.default`. +- The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API. +- Terminal and OS settings decide whether a requested notification is visibly displayed. +- Prefer privacy-safe messages such as `"A question needs your input"`; avoid full commands, paths, prompts, errors, secrets, or file contents unless the plugin intentionally exposes them. + ### Routes - Reserved route names: `home` and `session`. diff --git a/packages/opencode/specs/v2/notifications.md b/packages/opencode/specs/v2/notifications.md new file mode 100644 index 000000000..96018f994 --- /dev/null +++ b/packages/opencode/specs/v2/notifications.md @@ -0,0 +1,13 @@ +# TUI Notifications Default + +Problem: + +- v1 defaults `attention.enabled` to `false` +- users can opt in with `attention.enabled = true` +- v2 should make core TUI notifications a default behavior + +## v2 Target + +Flip `attention.enabled` to `true` by default in v2. + +Keep `attention.enabled = false` as the explicit opt-out. diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 867b830cf..aa123d599 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1094,8 +1094,8 @@ export class Agent implements ACPAgent { const currentModeId = await (async () => { if (!availableModes.length) return undefined - const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())) - const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id + const defaultAgent = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo())) + const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id this.sessionManager.setMode(sessionId, resolvedModeId) return resolvedModeId })() @@ -1328,7 +1328,8 @@ export class Agent implements ACPAgent { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))) + const agent = + session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultInfo()))).name const parts: Array< | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5917240cd..74ca1a402 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,5 +1,5 @@ import { Config } from "@/config/config" -import z from "zod" +import { ConfigPermission } from "@/config/permission" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" @@ -24,9 +24,7 @@ import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" -import { Reference } from "@/reference/reference" +import { type DeepMutable } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ name: Schema.String, @@ -48,14 +46,19 @@ export const Info = Schema.Struct({ prompt: Schema.optional(Schema.String), options: Schema.Record(Schema.String, Schema.Unknown), steps: Schema.optional(Schema.Finite), -}) - .annotate({ identifier: "Agent" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Agent" }) export type Info = DeepMutable> +const GeneratedAgent = Schema.Struct({ + identifier: Schema.String, + whenToUse: Schema.String, + systemPrompt: Schema.String, +}) + export interface Interface { readonly get: (agent: string) => Effect.Effect readonly list: () => Effect.Effect + readonly defaultInfo: () => Effect.Effect readonly defaultAgent: () => Effect.Effect readonly generate: (input: { description: string @@ -115,7 +118,10 @@ export const layer = Layer.effect( }, }) - const user = Permission.fromConfig(cfg.permission ?? {}) + // Convert permission layers to rulesets and merge them + // Each layer's rules come after the previous, so later configs override earlier ones + const layers = ConfigPermission.toLayers(cfg.permission) + const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p))) const agents: Record = { build: { @@ -205,7 +211,6 @@ export const layer = Layer.effect( glob: "allow", webfetch: "allow", websearch: "allow", - codesearch: "allow", read: "allow", repo_clone: "allow", repo_overview: "allow", @@ -301,76 +306,6 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } - function referencePrompt(reference: Reference.Resolved) { - if (reference.kind === "local") { - return [ - `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, - `Local directory: ${reference.path}`, - `Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, - `Return exact absolute file paths for findings whenever possible.`, - ].join("\n\n") - } - - if (reference.kind === "invalid") { - return [ - `You are configured reference @${reference.name}, but this reference is not usable yet.`, - `Configured repository: ${reference.repository}`, - `Problem: ${reference.message}`, - `Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`, - ].join("\n\n") - } - - return [ - `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, - `Repository: ${reference.repository}`, - ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), - `Cached directory: ${reference.path}`, - `OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`, - `Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`, - `Return exact absolute file paths for findings whenever possible.`, - ].join("\n\n") - } - - function referenceDescription(reference: Reference.Resolved) { - if (reference.kind === "local") return `Scout reference for local directory ${reference.path}` - if (reference.kind === "git") return `Scout reference for repository ${reference.repository}` - return `Invalid Scout reference for repository ${reference.repository}` - } - - if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) { - const resolvedReferences = Reference.resolveAll({ - references: cfg.reference ?? {}, - directory: ctx.directory, - worktree: ctx.worktree, - }) - for (const resolved of resolvedReferences) { - if (agents[resolved.name]) continue - const localPath = resolved.kind === "invalid" ? undefined : resolved.path - agents[resolved.name] = { - name: resolved.name, - description: referenceDescription(resolved), - permission: Permission.merge( - agents.scout.permission, - Permission.fromConfig({ - repo_clone: "deny", - ...(localPath - ? { - external_directory: { - [localPath]: "allow", - [path.join(localPath, "*")]: "allow", - }, - } - : {}), - }), - ), - prompt: referencePrompt(resolved), - options: { reference: cfg.reference?.[resolved.name], resolved }, - mode: "subagent", - native: false, - } - } - } - // Ensure Truncate.GLOB is allowed unless explicitly configured for (const name in agents) { const agent = agents[name] @@ -403,23 +338,28 @@ export const layer = Layer.effect( ) }) - const defaultAgent = Effect.fnUntraced(function* () { + const defaultInfo = Effect.fnUntraced(function* () { const c = yield* config.get() if (c.default_agent) { const agent = agents[c.default_agent] if (!agent) throw new Error(`default agent "${c.default_agent}" not found`) if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`) if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`) - return agent.name + return agent } const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) if (!visible) throw new Error("no primary visible agent found") - return visible.name + return visible + }) + + const defaultAgent = Effect.fnUntraced(function* () { + return (yield* defaultInfo()).name }) return { get, list, + defaultInfo, defaultAgent, } satisfies State }), @@ -432,6 +372,9 @@ export const layer = Layer.effect( list: Effect.fn("Agent.list")(function* () { return yield* InstanceState.useEffect(state, (s) => s.list()) }), + defaultInfo: Effect.fn("Agent.defaultInfo")(function* () { + return yield* InstanceState.useEffect(state, (s) => s.defaultInfo()) + }), defaultAgent: Effect.fn("Agent.defaultAgent")(function* () { return yield* InstanceState.useEffect(state, (s) => s.defaultAgent()) }), @@ -479,11 +422,10 @@ export const layer = Layer.effect( }, ], model: language, - schema: z.object({ - identifier: z.string(), - whenToUse: z.string(), - systemPrompt: z.string(), - }), + schema: Object.assign( + Schema.toStandardSchemaV1(GeneratedAgent), + Schema.toStandardJSONSchemaV1(GeneratedAgent), + ), } satisfies Parameters[0] if (isOpenaiOauth) { diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts index 1174ec31a..051f42e37 100644 --- a/packages/opencode/src/agent/subagent-permissions.ts +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -5,10 +5,10 @@ import type { Agent } from "./agent" * Build the `permission` ruleset for a subagent's session when it's spawned * via the task tool. Combines: * - * 1. The parent **agent's** deny rules — Plan Mode and other agent-level - * restrictions live on the agent ruleset, not on the session, so a + * 1. The parent **agent's** edit-class deny rules — Plan Mode's file-edit + * restriction lives on the agent ruleset, not on the session, so a * subagent that only inherited the parent SESSION's permission would - * silently bypass them. (#26514) + * silently bypass it. (#26514) * 2. The parent **session's** deny rules and external_directory rules — * same forwarding the original code already did. * 3. Default `todowrite` and `task` denies if the subagent's own ruleset @@ -21,7 +21,8 @@ export function deriveSubagentSessionPermission(input: { }): Permission.Ruleset { const canTask = input.subagent.permission.some((rule) => rule.permission === "task") const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") - const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? [] + const parentAgentDenies = + input.parentAgent?.permission.filter((rule) => rule.action === "deny" && rule.permission === "edit") ?? [] return [ ...parentAgentDenies, ...input.parentSessionPermission.filter( diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts index c7c947450..7b99d097a 100644 --- a/packages/opencode/src/audio.d.ts +++ b/packages/opencode/src/audio.d.ts @@ -3,6 +3,11 @@ declare module "*.wav" { export default file } +declare module "*.mp3" { + const file: string + export default file +} + declare module "*.wasm" { const file: string export default file diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index f7c631935..9d30ea142 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,6 +1,5 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" import { NonNegativeInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -32,9 +31,8 @@ export class WellKnown extends Schema.Class("WellKnownAuth")({ token: Schema.String, }) {} -const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) -export const Info = Object.assign(_Info, { zod: zod(_Info) }) -export type Info = Schema.Schema.Type +export const Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" }) +export type Info = Schema.Schema.Type export class AuthError extends Schema.TaggedErrorClass()("AuthError", { message: Schema.String, diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/background/job.ts new file mode 100644 index 000000000..3ea228f04 --- /dev/null +++ b/packages/opencode/src/background/job.ts @@ -0,0 +1,200 @@ +import { InstanceState } from "@/effect/instance-state" +import { Identifier } from "@/id/id" +import { Cause, Clock, Context, Deferred, Effect, Fiber, Layer, Scope, SynchronizedRef } from "effect" + +export type Status = "running" | "completed" | "error" | "cancelled" + +export type Info = { + id: string + type: string + title?: string + status: Status + started_at: number + completed_at?: number + output?: string + error?: string + metadata?: Record +} + +type Active = { + info: Info + done: Deferred.Deferred + fiber?: Fiber.Fiber +} + +type State = { + jobs: SynchronizedRef.SynchronizedRef> + scope: Scope.Scope +} + +type FinishResult = { + info?: Info + done?: Deferred.Deferred +} + +export type StartInput = { + id?: string + type: string + title?: string + metadata?: Record + run: Effect.Effect +} + +export type WaitInput = { + id: string + timeout?: number +} + +export type WaitResult = { + info?: Info + timedOut: boolean +} + +export interface Interface { + readonly list: () => Effect.Effect + readonly get: (id: string) => Effect.Effect + readonly start: (input: StartInput) => Effect.Effect + readonly wait: (input: WaitInput) => Effect.Effect + readonly cancel: (id: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/BackgroundJob") {} + +function snapshot(job: Active): Info { + return { + ...job.info, + ...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}), + } +} + +function errorText(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("BackgroundJob.state")(function* () { + return { + jobs: yield* SynchronizedRef.make(new Map()), + scope: yield* Scope.Scope, + } + }), + ) + + const finish = Effect.fn("BackgroundJob.finish")(function* ( + id: string, + status: Exclude, + data?: { output?: string; error?: string }, + ) { + const completed_at = yield* Clock.currentTimeMillis + const result = yield* SynchronizedRef.modify( + (yield* InstanceState.get(state)).jobs, + (jobs): readonly [FinishResult, Map] => { + const job = jobs.get(id) + if (!job) return [{}, jobs] + if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs] + const next = { + ...job, + fiber: undefined, + info: { + ...job.info, + status, + completed_at, + ...(data?.output !== undefined ? { output: data.output } : {}), + ...(data?.error !== undefined ? { error: data.error } : {}), + }, + } + return [{ info: snapshot(next), done: job.done }, new Map(jobs).set(id, next)] + }, + ) + if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore) + return result.info + }) + + const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () { + return Array.from((yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).values()) + .map(snapshot) + .toSorted((a, b) => a.started_at - b.started_at) + }) + + const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) { + const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(id) + if (!job) return + return snapshot(job) + }) + + const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) { + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const id = input.id ?? Identifier.ascending("job") + const started_at = yield* Clock.currentTimeMillis + const done = yield* Deferred.make() + return yield* SynchronizedRef.modifyEffect( + s.jobs, + Effect.fnUntraced(function* (jobs) { + const existing = jobs.get(id) + if (existing?.info.status === "running") return [snapshot(existing), jobs] as const + const fiber = yield* restore(input.run).pipe( + Effect.matchCauseEffect({ + onSuccess: (output) => finish(id, "completed", { output }), + onFailure: (cause) => + finish(id, Cause.hasInterruptsOnly(cause) ? "cancelled" : "error", { + error: errorText(Cause.squash(cause)), + }), + }), + Effect.asVoid, + Effect.forkIn(s.scope, { startImmediately: true }), + ) + const job = { + info: { + id, + type: input.type, + title: input.title, + status: "running" as const, + started_at, + metadata: input.metadata, + }, + done, + fiber, + } + return [snapshot(job), new Map(jobs).set(id, job)] as const + }), + ) + }), + ) + }) + + const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) { + const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(input.id) + if (!job) return { timedOut: false } + if (job.info.status !== "running") return { info: snapshot(job), timedOut: false } + if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false } + if (input.timeout <= 0) return { info: snapshot(job), timedOut: true } + const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout)) + if (info._tag === "Some") return { info: info.value, timedOut: false } + return { info: snapshot(job), timedOut: true } + }) + + const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) { + const job = (yield* SynchronizedRef.get((yield* InstanceState.get(state)).jobs)).get(id) + if (!job) return + if (job.info.status !== "running") return snapshot(job) + if (job.fiber) { + yield* Fiber.interrupt(job.fiber).pipe(Effect.ignore) + yield* Fiber.await(job.fiber).pipe(Effect.ignore) + } + const info = yield* finish(id, "cancelled") + return info + }) + + return Service.of({ list, get, start, wait, cancel }) + }), +) + +export const defaultLayer = layer + +export * as BackgroundJob from "./job" diff --git a/packages/opencode/src/cli/cmd/prompt-display.ts b/packages/opencode/src/cli/cmd/prompt-display.ts new file mode 100644 index 000000000..7ec4bc0af --- /dev/null +++ b/packages/opencode/src/cli/cmd/prompt-display.ts @@ -0,0 +1,39 @@ +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + +function displayOffsetIndex(value: string, offset: number) { + if (offset <= 0) return 0 + + let width = 0 + for (const part of graphemes.segment(value)) { + const next = width + Bun.stringWidth(part.segment) + if (next > offset) return part.index + width = next + } + + return value.length +} + +export function displaySlice(value: string, start = 0, end = Bun.stringWidth(value)) { + return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end)) +} + +export function displayCharAt(value: string, offset: number) { + let width = 0 + for (const part of graphemes.segment(value)) { + const next = width + Bun.stringWidth(part.segment) + if (offset === width || offset < next) return part.segment + width = next + } +} + +export function mentionTriggerIndex(value: string, offset = Bun.stringWidth(value)) { + const text = displaySlice(value, 0, offset) + const index = text.lastIndexOf("@") + if (index === -1) return + + const before = index === 0 ? undefined : text[index - 1] + const query = text.slice(index) + if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) { + return Bun.stringWidth(text.slice(0, index)) + } +} diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 749139e2d..426ea89fc 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -124,6 +124,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( yield* put(saveProvider, { type: "api", key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), }) } yield* spinner.stop("Login successful") @@ -156,6 +157,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( yield* put(saveProvider, { type: "api", key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), }) } yield* Prompt.log.success("Login successful") @@ -191,10 +193,11 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( } if (result.type === "success") { const saveProvider = result.provider ?? provider + const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) } yield* put(saveProvider, { type: "api", key: result.key ?? apiKey, - ...metadata, + ...(Object.keys(merged).length ? { metadata: merged } : {}), }) yield* Prompt.log.success("Login successful") } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index bca89c3ca..7011b51eb 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -719,6 +719,7 @@ export const RunCommand = effectCmd({ } } } + return error } const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) const client = args.attach ? attachSDK(cwd) : sdk @@ -730,10 +731,7 @@ export const RunCommand = effectCmd({ if (!args.interactive) { const events = await client.event.subscribe() - loop(client, events).catch((e) => { - console.error(e) - process.exit(1) - }) + const completed = loop(client, events) if (args.command) { await client.session.command({ @@ -744,6 +742,8 @@ export const RunCommand = effectCmd({ arguments: message, variant: args.variant, }) + const error = await completed + if (error) process.exitCode = 1 return } @@ -755,6 +755,8 @@ export const RunCommand = effectCmd({ variant: args.variant, parts: [...files, { type: "text", text: message }], }) + const error = await completed + if (error) process.exitCode = 1 return } diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 8cd4fbfcf..54f20dbc0 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -14,7 +14,10 @@ import { createEffect, createMemo, createResource, createSignal, onCleanup, onMo import * as Locale from "@/util/locale" import { createPromptHistory, + displayCharAt, + displaySlice, isExitCommand, + mentionTriggerIndex, isNewCommand, movePromptHistory, promptCycle, @@ -537,7 +540,7 @@ export function createPromptState(input: PromptInput): PromptState { }) } - const restore = (value: RunPrompt, cursor = value.text.length) => { + const restore = (value: RunPrompt, cursor = Bun.stringWidth(value.text)) => { draft = clonePrompt(value) if (!area || area.isDestroyed) { return @@ -546,7 +549,7 @@ export function createPromptState(input: PromptInput): PromptState { hide() area.setText(value.text) restoreParts(value.parts) - area.cursorOffset = Math.min(cursor, area.plainText.length) + area.cursorOffset = Math.min(cursor, Bun.stringWidth(area.plainText)) scheduleRows() area.focus() } @@ -577,7 +580,7 @@ export function createPromptState(input: PromptInput): PromptState { area.setText(text) clearParts() draft = { text: area.plainText, parts: [] } - area.cursorOffset = Math.min(text.length, area.plainText.length) + area.cursorOffset = Math.min(Bun.stringWidth(text), Bun.stringWidth(area.plainText)) scheduleRows() area.focus() } @@ -610,12 +613,13 @@ export function createPromptState(input: PromptInput): PromptState { } if (visible() && mode() === "mention") { - if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) { + const query = displaySlice(text, at(), cursor) + if (cursor <= at() || /\s/.test(query)) { hide() return } - setQuery(text.slice(at() + 1, cursor)) + setQuery(displaySlice(text, at() + 1, cursor)) return } @@ -623,19 +627,12 @@ export function createPromptState(input: PromptInput): PromptState { return } - const head = text.slice(0, cursor) - const idx = head.lastIndexOf("@") - if (idx === -1) { - return - } - - const before = idx === 0 ? undefined : head[idx - 1] - const tail = head.slice(idx) - if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) { + const idx = mentionTriggerIndex(text, cursor) + if (idx !== undefined) { setAt(idx) menu.reset() setMode("mention") - setQuery(head.slice(idx + 1)) + setQuery(displaySlice(text, idx + 1, cursor)) } } @@ -782,7 +779,7 @@ export function createPromptState(input: PromptInput): PromptState { } const cursor = area.cursorOffset - const tail = area.plainText.at(cursor) + const tail = displayCharAt(area.plainText, cursor) const append = "@" + next.value + (tail === " " ? "" : " ") area.cursorOffset = at() const start = area.logicalCursor @@ -941,7 +938,8 @@ export function createPromptState(input: PromptInput): PromptState { } const dir = up ? -1 : 1 - if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === area.plainText.length)) { + const endOffset = Bun.stringWidth(area.plainText) + if ((dir === -1 && area.cursorOffset === 0) || (dir === 1 && area.cursorOffset === endOffset)) { move(dir, event) return } @@ -955,7 +953,7 @@ export function createPromptState(input: PromptInput): PromptState { ? area.height - 1 : Math.max(0, (area.virtualLineCount ?? 1) - 1) if (dir === 1 && area.visualCursor.visualRow === end) { - area.cursorOffset = area.plainText.length + area.cursorOffset = endOffset } } diff --git a/packages/opencode/src/cli/cmd/run/prompt.shared.ts b/packages/opencode/src/cli/cmd/run/prompt.shared.ts index 1b639e6e7..0da787cb3 100644 --- a/packages/opencode/src/cli/cmd/run/prompt.shared.ts +++ b/packages/opencode/src/cli/cmd/run/prompt.shared.ts @@ -12,6 +12,7 @@ // The leader-key cycle (promptCycle) uses a two-step pattern: first press // arms the leader, second press within the timeout fires the action. import type { KeyBinding } from "@opentui/core" +export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display" import { formatBinding, parseBindings } from "./keymap.shared" import type { FooterKeybinds, RunPrompt } from "./types" @@ -275,7 +276,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: return { state, apply: false } } - if (dir === 1 && cursor !== text.length) { + if (dir === 1 && cursor !== Bun.stringWidth(text)) { return { state, apply: false } } @@ -309,7 +310,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: index: null, }, text: state.draft, - cursor: state.draft.length, + cursor: Bun.stringWidth(state.draft), apply: true, } } @@ -320,7 +321,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: index: idx, }, text: state.items[idx].text, - cursor: dir === -1 ? 0 : state.items[idx].text.length, + cursor: dir === -1 ? 0 : Bun.stringWidth(state.items[idx].text), apply: true, } } diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 0124a2693..3dadea9dd 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -164,8 +164,8 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( Effect.gen(function* () { const messages = yield* svc.messages({ sessionID: session.id }) - let sessionCost = 0 - let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + const sessionCost = session.cost ?? 0 + const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } let sessionToolUsage: Record = {} let sessionModelUsage: Record< string, @@ -178,8 +178,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( for (const message of messages) { if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 - const modelKey = `${message.info.providerID}/${message.info.modelID}` if (!sessionModelUsage[modelKey]) { sessionModelUsage[modelKey] = { @@ -192,12 +190,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( sessionModelUsage[modelKey].cost += message.info.cost || 0 if (message.info.tokens) { - sessionTokens.input += message.info.tokens.input || 0 - sessionTokens.output += message.info.tokens.output || 0 - sessionTokens.reasoning += message.info.tokens.reasoning || 0 - sessionTokens.cache.read += message.info.tokens.cache?.read || 0 - sessionTokens.cache.write += message.info.tokens.cache?.write || 0 - sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 sessionModelUsage[modelKey].tokens.output += (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index cc2afd1cd..29cca133b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -2,6 +2,7 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import * as Clipboard from "@tui/util/clipboard" import * as Selection from "@tui/util/selection" +import * as TuiAudio from "@tui/util/audio" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { @@ -63,6 +64,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import type { RouteMap } from "@/cli/cmd/tui/plugin/api" +import { createTuiAttention } from "@/cli/cmd/tui/attention" import { FormatError, FormatUnknownError } from "@/cli/error" import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette" import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap" @@ -74,6 +76,17 @@ const appBindingCommands = [ "command.palette.show", "session.list", "session.new", + "session.cycle_recent", + "session.cycle_recent_reverse", + "session.quick_switch.1", + "session.quick_switch.2", + "session.quick_switch.3", + "session.quick_switch.4", + "session.quick_switch.5", + "session.quick_switch.6", + "session.quick_switch.7", + "session.quick_switch.8", + "session.quick_switch.9", "model.list", "model.cycle_recent", "model.cycle_recent_reverse", @@ -165,10 +178,10 @@ export function tui(input: { unguard?.() resolve() } - const onBeforeExit = async () => { offKeymap() await TuiPluginRuntime.dispose() + TuiAudio.dispose() } const renderer = await createCliRenderer(rendererConfig(input.config)) @@ -272,6 +285,7 @@ function App(props: { onSnapshot?: () => Promise }) { routeRev() return routes.get(name)?.at(-1)?.render } + const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) const api = createTuiApi({ tuiConfig, @@ -287,11 +301,13 @@ function App(props: { onSnapshot?: () => Promise }) { theme: themeState, toast, renderer, + attention, }) const [ready, setReady] = createSignal(false) TuiPluginRuntime.init({ api, config: tuiConfig, + dispose: () => attention.dispose(), }) .catch((error) => { console.error("Failed to load TUI plugins", error) @@ -309,7 +325,10 @@ function App(props: { onSnapshot?: () => Promise }) { }, { priority: 1 }, ) - onCleanup(offSelectionKeys) + onCleanup(() => { + offSelectionKeys() + attention.dispose() + }) // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { @@ -462,6 +481,37 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, + ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING + ? [ + { + name: "session.cycle_recent", + title: "Cycle to previous recent session", + category: "Session", + hidden: true, + run: () => { + local.session.cycleRecent(1) + }, + }, + { + name: "session.cycle_recent_reverse", + title: "Cycle to next recent session", + category: "Session", + hidden: true, + run: () => { + local.session.cycleRecent(-1) + }, + }, + ...Array.from({ length: 9 }, (_, i) => ({ + name: `session.quick_switch.${i + 1}`, + title: `Switch to session in quick slot ${i + 1}`, + category: "Session", + hidden: true, + run: () => { + local.session.quickSwitch(i + 1) + }, + })), + ] + : []), { name: "model.list", title: "Switch model", @@ -776,7 +826,14 @@ function App(props: { onSnapshot?: () => Promise }) { useBindings(() => ({ enabled: command.matcher, - bindings: tuiConfig.keybinds.gather("app", appBindingCommands), + bindings: tuiConfig.keybinds.gather( + "app", + Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING + ? appBindingCommands + : appBindingCommands.filter( + (c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"), + ), + ), })) useBindings(() => ({ diff --git a/packages/opencode/src/cli/cmd/tui/asset/charge.wav b/packages/opencode/src/cli/cmd/tui/asset/charge.wav deleted file mode 100644 index d9597899c..000000000 Binary files a/packages/opencode/src/cli/cmd/tui/asset/charge.wav and /dev/null differ diff --git a/packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav b/packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav deleted file mode 100644 index 2ebb6a38b..000000000 Binary files a/packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav and /dev/null differ diff --git a/packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav b/packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav deleted file mode 100644 index 4e1b59c96..000000000 Binary files a/packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav and /dev/null differ diff --git a/packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav b/packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav deleted file mode 100644 index feb56cacd..000000000 Binary files a/packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav and /dev/null differ diff --git a/packages/opencode/src/cli/cmd/tui/attention.ts b/packages/opencode/src/cli/cmd/tui/attention.ts new file mode 100644 index 000000000..f6480961d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/attention.ts @@ -0,0 +1,262 @@ +import type { + TuiAttention, + TuiAttentionNotifyInput, + TuiAttentionNotifyResult, + TuiAttentionNotifySkipReason, + TuiAttentionWhen, + TuiKV, + TuiAttentionSoundName, + TuiAttentionSoundPack, + TuiAttentionSoundPackInfo, +} from "@opencode-ai/plugin/tui" +import stripAnsi from "strip-ansi" +import type { TuiConfig } from "./config/tui" +import { isAttentionSoundName } from "./config/tui-schema" +import * as TuiAudio from "@tui/util/audio" +import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } +import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" } +import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" } +import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" } +import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } +import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" } +import * as Log from "@opencode-ai/core/util/log" + +type FocusState = "unknown" | "focused" | "blurred" + +type AttentionRenderer = { + readonly isDestroyed: boolean + on(event: "focus" | "blur", listener: () => void): unknown + off(event: "focus" | "blur", listener: () => void): unknown + triggerNotification(message: string, title?: string): boolean +} + +type RegisteredSoundPack = TuiAttentionSoundPack & { + builtin: boolean +} + +type TuiAttentionHost = TuiAttention & { + dispose(): void +} + +const log = Log.create({ service: "tui.attention" }) + +const DEFAULT_TITLE = "opencode" +const DEFAULT_PACK_ID = "opencode.default" +const KV_SOUND_PACK = "attention_sound_pack" +const TITLE_LIMIT = 80 +const MESSAGE_LIMIT = 240 +const BUILTIN_PACK: RegisteredSoundPack = { + id: DEFAULT_PACK_ID, + name: "OpenCode Default", + builtin: true, + sounds: { + default: defaultSoundPath, + question: questionSoundPath, + permission: permissionSoundPath, + error: errorSoundPath, + done: doneSoundPath, + subagent_done: subagentDoneSoundPath, + }, +} + +function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult { + return { + ok: false, + notification: false, + sound: false, + skipped: reason, + } +} + +function normalizeText(input: string | undefined, fallback: string, limit: number) { + const text = stripAnsi(input ?? "") + .replace(/[ \t]*[\r\n]+[ \t]*/g, " ") + .replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "") + .trim() + const normalized = text.length ? text : fallback + return Array.from(normalized).slice(0, limit).join("") +} + +function clampVolume(volume: number) { + if (!Number.isFinite(volume)) return 0 + return Math.min(1, Math.max(0, volume)) +} + +function soundVolume(input: TuiAttentionNotifyInput, config: Pick) { + if (!config.attention.sound) return + if (input.sound === false) return + if (input.sound === undefined) return clampVolume(config.attention.volume) + if (input.sound === true) return clampVolume(config.attention.volume) + return clampVolume(input.sound.volume ?? config.attention.volume) +} + +function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined { + const id = pack.id.trim() + if (!id) return + return { + id, + name: pack.name?.trim() || undefined, + builtin: false, + sounds: Object.fromEntries( + Object.entries(pack.sounds).filter( + (item): item is [TuiAttentionSoundName, string] => + isAttentionSoundName(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0, + ), + ), + } +} + +function focusSkip(when: TuiAttentionWhen, focus: FocusState) { + if (when === "always") return + if (focus === "unknown") return "focus_unknown" + if (when === "blurred" && focus === "focused") return "focused" + if (when === "focused" && focus === "blurred") return "blurred" +} + +export function createTuiAttention(input: { + renderer: AttentionRenderer + config: Pick + kv?: TuiKV + audio?: Pick +}): TuiAttentionHost { + let focus: FocusState = "unknown" + let disposed = false + let activePackID: string | undefined + const packs = new Map([[BUILTIN_PACK.id, BUILTIN_PACK]]) + const audio = input.audio ?? TuiAudio + + const onFocus = () => { + focus = "focused" + } + const onBlur = () => { + focus = "blurred" + } + + input.renderer.on("focus", onFocus) + input.renderer.on("blur", onBlur) + + function configuredPackID() { + const stored = input.kv?.get(KV_SOUND_PACK, undefined) + return activePackID ?? stored ?? input.config.attention.sound_pack + } + + function currentPack() { + return packs.get(configuredPackID()) ?? BUILTIN_PACK + } + + function soundCandidates(name: TuiAttentionSoundName) { + return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter( + (item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index, + ) + } + + async function playSound(name: TuiAttentionSoundName, volume: number) { + try { + for (const file of soundCandidates(name)) { + const current = await audio.loadSoundFile(file).catch((error) => { + log.debug("failed to load attention sound", { file, error }) + return null + }) + if (disposed) return false + if (current == null) continue + if (audio.play(current, { volume }) != null) return true + } + return false + } catch (error) { + log.debug("failed to play attention sound", { error }) + return false + } + } + + return { + async notify(request) { + try { + if (!input.config.attention.enabled) return skipped("attention_disabled") + if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed") + + const message = normalizeText(request.message, "", MESSAGE_LIMIT) + if (!message) return skipped("empty_message") + + const requestedNotification = typeof request.notification === "object" ? request.notification : undefined + const notificationSkip = focusSkip(requestedNotification?.when ?? "blurred", focus) + const notificationRequested = input.config.attention.notifications && request.notification !== false + const shouldNotify = notificationRequested && !notificationSkip + const notification = shouldNotify + ? (() => { + try { + return input.renderer.triggerNotification( + message, + normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT), + ) + } catch (error) { + log.debug("failed to trigger attention notification", { error }) + return false + } + })() + : false + const volume = soundVolume(request, input.config) + const requestedSound = typeof request.sound === "object" ? request.sound : undefined + const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus) + const soundName = + requestedSound?.name && isAttentionSoundName(requestedSound.name) ? requestedSound.name : "default" + const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume) + + if (!notification && !sound) { + if (notificationRequested && notificationSkip) return skipped(notificationSkip) + if (soundSkip) return skipped(soundSkip) + } + + return { + ok: notification || sound, + notification, + sound, + } + } catch (error) { + log.debug("failed to handle attention notification", { error }) + return { + ok: false, + notification: false, + sound: false, + } + } + }, + soundboard: { + registerPack(pack) { + const next = normalizePack(pack) + if (!next) return () => {} + packs.set(next.id, next) + let disposed = false + return () => { + if (disposed) return + disposed = true + if (packs.get(next.id) === next) packs.delete(next.id) + } + }, + activate(id, options) { + const pack = packs.get(id) + if (!pack) return false + activePackID = pack.id + if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id) + return true + }, + current() { + return currentPack().id + }, + list(): TuiAttentionSoundPackInfo[] { + const current = currentPack().id + return Array.from(packs.values()).map((pack) => ({ + id: pack.id, + name: pack.name, + active: pack.id === current, + builtin: pack.builtin, + })) + }, + }, + dispose() { + if (disposed) return + disposed = true + input.renderer.off("focus", onFocus) + input.renderer.off("blur", onBlur) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 35c966937..1dd33106d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -7,6 +7,7 @@ import { Locale } from "@/util/locale" import { useProject } from "@tui/context/project" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" +import { useLocal } from "../context/local" import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" @@ -25,6 +26,7 @@ export function DialogSessionList() { const project = useProject() const { theme } = useTheme() const sdk = useSDK() + const local = useLocal() const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) @@ -128,7 +130,10 @@ export function DialogSessionList() { const [browseOrder] = createSignal(orderByRecency(sync.data.session)) + const RECENT_LIMIT = 5 + const options = createMemo(() => { + const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING const today = new Date().toDateString() const sessionMap = new Map( sessions() @@ -139,46 +144,74 @@ export function DialogSessionList() { const searchResult = searchResults() const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() - return displayOrder - .map((id) => sessionMap.get(id)) - .filter((x) => x !== undefined) - .map((x) => { - const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined + const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set() + const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : [] + const pinnedSet = new Set(pinned) + const slotByID = enabled + ? new Map(local.session.slots().map((id, i) => [id, i + 1])) + : new Map() - let footer: JSX.Element | string = "" - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - if (x.workspaceID) { - footer = workspace ? ( - - ) : ( - - ) - } - } else { - footer = Locale.time(x.time.updated) - } + const recent = enabled + ? displayOrder.filter((id) => !pinnedSet.has(id) && !dismissed.has(id)).slice(0, RECENT_LIMIT) + : [] + const recentSet = new Set(recent) - const date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" || status?.type === "retry" - return { - title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer, - gutter: isWorking ? () => : undefined, + function buildOption(id: string, category: string) { + const x = sessionMap.get(id) + if (!x) return undefined + const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined + + let footer: JSX.Element | string = "" + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (x.workspaceID) { + footer = workspace ? ( + + ) : ( + + ) } + } else { + footer = Locale.time(x.time.updated) + } + + const isDeleting = toDelete() === x.id + const status = sync.data.session_status?.[x.id] + const isWorking = status?.type === "busy" || status?.type === "retry" + const slot = slotByID.get(x.id) + const gutter = isWorking + ? () => + : slot !== undefined + ? () => {slot} + : undefined + return { + title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title, + bg: isDeleting ? theme.error : undefined, + value: x.id, + category, + footer, + gutter, + } + } + + const remaining = displayOrder + .filter((id) => !pinnedSet.has(id) && !recentSet.has(id)) + .map((id) => { + const x = sessionMap.get(id) + if (!x) return undefined + const label = new Date(x.time.updated).toDateString() + return buildOption(id, label === today ? "Today" : label) }) + .filter((x) => x !== undefined) + + return [ + ...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), + ...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined), + ...remaining, + ] }) onMount(() => { @@ -203,6 +236,32 @@ export function DialogSessionList() { dialog.clear() }} actions={[ + ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING + ? [ + { + command: "session.pin.toggle", + title: "pin/unpin", + onTrigger: (option: { value: string }) => { + local.session.togglePin(option.value) + }, + }, + { + command: "session.toggle.recent", + title: "toggle recent", + onTrigger: (option: { value: string }) => { + if (local.session.isPinned(option.value)) { + toast.show({ + variant: "info", + message: "Unpin the session first to toggle it in Recent", + duration: 3000, + }) + return + } + local.session.toggleRecent(option.value) + }, + }, + ] + : []), { command: "session.delete", title: "delete", diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index e3e8074cd..557b86877 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -2,7 +2,6 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@o import { useRenderer } from "@opentui/solid" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" -import * as Sound from "@tui/util/sound" import { go, logo } from "@/cli/logo" export type LogoShape = { @@ -563,7 +562,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = const [now, setNow] = createSignal(0) let box: BoxRenderable | undefined let timer: ReturnType | undefined - let hum = false const stop = () => { if (!timer) return @@ -575,10 +573,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = const t = performance.now() setNow(t) const item = hold() - if (item && !hum && t - item.at >= HOLD) { - hum = true - Sound.start() - } if (item && t - item.at >= CHARGE) { burst(item.x, item.y) } @@ -605,8 +599,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = onCleanup(() => { stop() - hum = false - Sound.dispose() }) onMount(() => { @@ -626,14 +618,12 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = setNow(t) if (!last) setRelease(undefined) setHold({ x, y, at: t, glyph: select(x, y, ctx) }) - hum = false start() } const burst = (x: number, y: number) => { const item = hold() if (!item) return - hum = false const t = performance.now() const age = t - item.at const rise = ramp(age, HOLD, CHARGE) @@ -655,7 +645,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = ]) setNow(t) start() - Sound.pulse(lerp(0.8, 1, level)) } const frame = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 7f390f0eb..3f7604653 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -18,6 +18,9 @@ import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" import { useBindings } from "../../keymap" +import { Reference } from "@/reference/reference" +import type { Config } from "@/config/config" +import { displayCharAt, mentionTriggerIndex } from "@/cli/cmd/prompt-display" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -157,7 +160,7 @@ export function Autocomplete(props: { const input = props.input() const currentCursorOffset = input.cursorOffset - const charAfterCursor = props.value.at(currentCursorOffset) + const charAfterCursor = displayCharAt(props.value, currentCursorOffset) const needsSpace = charAfterCursor !== " " const append = "@" + text + (needsSpace ? " " : "") @@ -260,6 +263,87 @@ export function Autocomplete(props: { } } + function createReferenceFilePart(input: { + alias: string + root: string + item: string + lineRange?: { startLine: number; endLine?: number } + }) { + const filename = `${input.alias}/${ + input.lineRange && !input.item.endsWith("/") + ? `${input.item}#${input.lineRange.startLine}${input.lineRange.endLine ? `-${input.lineRange.endLine}` : ""}` + : input.item + }` + const urlObj = pathToFileURL(path.join(input.root, input.item)) + + if (input.lineRange && !input.item.endsWith("/")) { + urlObj.searchParams.set("start", String(input.lineRange.startLine)) + if (input.lineRange.endLine !== undefined) { + urlObj.searchParams.set("end", String(input.lineRange.endLine)) + } + } + + return { + filename, + url: urlObj.href, + part: { + type: "file" as const, + mime: input.item.endsWith("/") ? "application/x-directory" : "text/plain", + filename, + url: urlObj.href, + source: { + type: "file" as const, + text: { + start: 0, + end: 0, + value: "", + }, + path: filename, + }, + }, + } + } + + function referencePromptText(reference: Reference.Resolved) { + const problem = reference.kind === "invalid" ? reference.message : undefined + return [ + `Referenced configured reference @${reference.name}.`, + ...(reference.kind === "local" ? ["Kind: local directory"] : []), + ...(reference.kind === "git" ? ["Kind: git repository"] : []), + ...(reference.kind === "invalid" ? [`Repository: ${reference.repository}`] : []), + ...(reference.kind === "git" ? [`Repository: ${reference.repository}`] : []), + ...(reference.kind === "git" && reference.branch ? [`Branch/ref: ${reference.branch}`] : []), + ...(reference.kind === "invalid" ? [] : [`Reference root: ${reference.path}`]), + ...(problem + ? [`Problem: ${problem}`] + : [ + "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", + ]), + ].join("\n") + } + + const references = createMemo(() => + Reference.resolveAll({ + references: (sync.data.config.reference ?? {}) as NonNullable, + directory: sync.path.directory || process.cwd(), + worktree: sync.path.worktree || sync.path.directory || process.cwd(), + }), + ) + + const referenceSearch = createMemo(() => { + if (!store.visible || store.visible === "/") return + const { lineRange, baseQuery } = extractLineRange(search()) + const slash = baseQuery.indexOf("/") + if (slash === -1) return + const reference = references().find((item) => item.name === baseQuery.slice(0, slash)) + if (!reference || reference.kind === "invalid") return + return { + reference, + query: baseQuery.slice(slash + 1), + lineRange, + } + }) + function normalizeMentionPath(filePath: string) { const baseDir = sync.path.directory || process.cwd() const absolute = path.resolve(filePath) @@ -291,6 +375,7 @@ export function Autocomplete(props: { () => search(), async (query) => { if (!store.visible || store.visible === "/") return [] + if (referenceSearch()) return [] const { lineRange, baseQuery } = extractLineRange(query ?? "") @@ -339,6 +424,43 @@ export function Autocomplete(props: { }, ) + const [referenceFiles] = createResource( + () => referenceSearch(), + async (match) => { + if (!match) return [] + + const result = await sdk.client.find.files({ + directory: match.reference.path, + query: match.query, + limit: 50, + }) + + if (result.error || !result.data) return [] + + const width = props.anchor().width - 4 + return result.data.map((item): AutocompleteOption => { + const { filename, part } = createReferenceFilePart({ + alias: match.reference.name, + root: match.reference.path, + item, + lineRange: match.lineRange, + }) + return { + display: Locale.truncateMiddle(filename, width), + value: filename, + isDirectory: item.endsWith("/"), + path: filename, + onSelect: () => { + insertPart(filename, part) + }, + } + }) + }, + { + initialValue: [], + }, + ) + const mcpResources = createMemo(() => { if (!store.visible || store.visible === "/") return [] @@ -397,6 +519,22 @@ export function Autocomplete(props: { ) }) + const referenceAliases = createMemo(() => + references().map( + (reference): AutocompleteOption => ({ + display: "@" + reference.name, + description: reference.kind === "invalid" ? reference.message : " configured reference", + onSelect: () => { + insertPart(reference.name, { + type: "text", + text: referencePromptText(reference), + synthetic: true, + }) + }, + }), + ), + ) + const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...command.slashes()] @@ -428,11 +566,18 @@ export function Autocomplete(props: { const options = createMemo((prev: AutocompleteOption[] | undefined) => { const filesValue = files() + const referenceFilesValue = referenceFiles() + const referenceSearchValue = referenceSearch() const agentsValue = agents() + const referenceAliasesValue = referenceAliases() const commandsValue = commands() const mixed: AutocompleteOption[] = - store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] + store.visible === "@" + ? referenceSearchValue + ? referenceFilesValue || [] + : [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()] + : [...commandsValue] const searchValue = search() @@ -440,7 +585,7 @@ export function Autocomplete(props: { return mixed } - if (files.loading && prev && prev.length > 0) { + if ((files.loading || referenceFiles.loading) && prev && prev.length > 0) { return prev } @@ -505,7 +650,7 @@ export function Autocomplete(props: { const input = props.input() const currentCursorOffset = input.cursorOffset - const displayText = selected.display.trimEnd() + const displayText = (selected.value ?? selected.display).trimEnd() const path = displayText.startsWith("@") ? displayText.slice(1) : displayText input.cursorOffset = store.index @@ -643,13 +788,8 @@ export function Autocomplete(props: { } // Check for "@" trigger - find the nearest "@" before cursor with no whitespace between - const text = value.slice(0, offset) - const idx = text.lastIndexOf("@") - if (idx === -1) return - - const between = text.slice(idx) - const before = idx === 0 ? undefined : value[idx - 1] - if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) { + const idx = mentionTriggerIndex(value, offset) + if (idx !== undefined) { show("@") setStore("index", idx) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f3217fcba..c80daf9cf 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -337,6 +337,7 @@ export function Prompt(props: PromptProps) { const usage = createMemo(() => { if (!props.sessionID) return + const session = sync.session.get(props.sessionID) const msg = sync.data.message[props.sessionID] ?? [] const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) if (!last) return @@ -347,7 +348,7 @@ export function Prompt(props: PromptProps) { const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) + const cost = session?.cost ?? 0 return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, @@ -989,7 +990,24 @@ export function Prompt(props: PromptProps) { } }) + let submitting = false async function submit() { + // Prevent overlapping invocations (e.g. a double-pressed Enter, or the + // input's native onSubmit racing another dispatch). Without this guard, + // a second call slips past the empty-input check before the first call + // clears `store.prompt.input`, then awaits its own `session.create` and + // ultimately reads the now-empty store — sending a phantom empty prompt + // to a freshly created session. + if (submitting) return false + submitting = true + try { + return await submitInner() + } finally { + submitting = false + } + } + + async function submitInner() { setWarpNotice(undefined) // IME: double-defer may fire before onContentChange flushes the last diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index 46a48e18e..462389316 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -2,34 +2,40 @@ export * as TuiKeybind from "./keybind" import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" -import type { BindingCommandMap, BindingConfig, BindingDefaults, BindingValue } from "@opentui/keymap/extras" -import z from "zod" +import type { BindingCommandMap, BindingConfig, BindingDefaults } from "@opentui/keymap/extras" +import type { DeepMutable } from "@opencode-ai/core/schema" +import { Schema } from "effect" -const KeyStroke = z - .object({ - name: z.string(), - ctrl: z.boolean().optional(), - shift: z.boolean().optional(), - meta: z.boolean().optional(), - super: z.boolean().optional(), - hyper: z.boolean().optional(), - }) - .strict() +const KeyStroke = Schema.Struct({ + name: Schema.String, + ctrl: Schema.optional(Schema.Boolean), + shift: Schema.optional(Schema.Boolean), + meta: Schema.optional(Schema.Boolean), + super: Schema.optional(Schema.Boolean), + hyper: Schema.optional(Schema.Boolean), +}) -const BindingObject = z - .object({ - key: z.union([z.string(), KeyStroke]), - event: z.enum(["press", "release"]).optional(), - preventDefault: z.boolean().optional(), - fallthrough: z.boolean().optional(), - }) - .passthrough() +const BindingObject = Schema.StructWithRest( + Schema.Struct({ + key: Schema.Union([Schema.String, KeyStroke]), + event: Schema.optional(Schema.Literals(["press", "release"])), + preventDefault: Schema.optional(Schema.Boolean), + fallthrough: Schema.optional(Schema.Boolean), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) -const BindingItem = z.union([z.string(), KeyStroke, BindingObject]) -export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)]) +const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject]) +export const BindingValueSchema = Schema.Union([ + Schema.Literal(false), + Schema.Literal("none"), + BindingItem, + Schema.Array(BindingItem), +]) +export type BindingValueSchema = DeepMutable> type Definition = { - default: z.input + default: BindingValueSchema description: string } @@ -38,7 +44,7 @@ export const LeaderDefault = "ctrl+x" const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description }) -const Definitions = { +export const Definitions = { leader: keybind(LeaderDefault, "Leader key for keybind combinations"), app_exit: keybind("ctrl+c,ctrl+d,q", "Exit the application"), @@ -80,6 +86,19 @@ const Definitions = { session_child_cycle: keybind("right", "Go to next child session"), session_child_cycle_reverse: keybind("left", "Go to previous child session"), session_parent: keybind("up", "Go to parent session"), + session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"), + session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"), + session_cycle_recent: keybind("]", "Cycle to the previous recent session"), + session_cycle_recent_reverse: keybind("[", "Cycle to the next recent session"), + session_quick_switch_1: keybind("1", "Switch to session in quick slot 1"), + session_quick_switch_2: keybind("2", "Switch to session in quick slot 2"), + session_quick_switch_3: keybind("3", "Switch to session in quick slot 3"), + session_quick_switch_4: keybind("4", "Switch to session in quick slot 4"), + session_quick_switch_5: keybind("5", "Switch to session in quick slot 5"), + session_quick_switch_6: keybind("6", "Switch to session in quick slot 6"), + session_quick_switch_7: keybind("7", "Switch to session in quick slot 7"), + session_quick_switch_8: keybind("8", "Switch to session in quick slot 8"), + session_quick_switch_9: keybind("9", "Switch to session in quick slot 9"), stash_delete: keybind("ctrl+d", "Delete stash entry"), model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), @@ -201,21 +220,17 @@ const Definitions = { which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"), } satisfies Record -type KeybindName = keyof typeof Definitions & string +type KeybindName = keyof typeof Definitions +const KeybindNames = new Set(Object.keys(Definitions)) -const KeybindShape = Object.fromEntries( - Object.entries(Definitions).map(([name, item]) => [ - name, - BindingValueSchema.optional().default(item.default).describe(item.description), - ]), -) as Record>> - -const KeybindOverrideShape = Object.fromEntries( - Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]), -) as Record> - -export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration") -export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides") +export const KeybindOverrides = Schema.Struct( + Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + Schema.optional(BindingValueSchema).annotate({ description: item.description }), + ]), + ), +).annotate({ description: "TUI keybinding overrides" }) export const Descriptions = Object.fromEntries( Object.entries(Definitions).map(([name, item]) => [name, item.description]), ) as Record @@ -257,6 +272,19 @@ export const CommandMap = { session_child_cycle: "session.child.next", session_child_cycle_reverse: "session.child.previous", session_parent: "session.parent", + session_pin_toggle: "session.pin.toggle", + session_toggle_recent: "session.toggle.recent", + session_cycle_recent: "session.cycle_recent", + session_cycle_recent_reverse: "session.cycle_recent_reverse", + session_quick_switch_1: "session.quick_switch.1", + session_quick_switch_2: "session.quick_switch.2", + session_quick_switch_3: "session.quick_switch.3", + session_quick_switch_4: "session.quick_switch.4", + session_quick_switch_5: "session.quick_switch.5", + session_quick_switch_6: "session.quick_switch.6", + session_quick_switch_7: "session.quick_switch.7", + session_quick_switch_8: "session.quick_switch.8", + session_quick_switch_9: "session.quick_switch.9", stash_delete: "stash.delete", model_provider_list: "model.dialog.provider", model_favorite_toggle: "model.dialog.favorite", @@ -361,8 +389,8 @@ const CommandDescriptions = Object.fromEntries( ]), ) as Record -export type Keybinds = z.output -export type KeybindOverrides = z.output +export type Keybinds = { [K in KeybindName]: BindingValueSchema } +export type KeybindOverrides = Partial export type BindingLookupView = { readonly bindings: readonly Binding[] get(command: string): readonly Binding[] @@ -376,6 +404,29 @@ export function toBindingConfig(keybinds: Keybinds): BindingConfig } +const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema) + +export function defaultValue(name: KeybindName) { + return Definitions[name].default +} + +export function parse(keybinds: KeybindOverrides): Keybinds { + const invalid = unknownKeys(keybinds) + if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`) + return Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + decodeBindingValue(keybinds[name as KeybindName] ?? item.default), + ]), + ) as Keybinds +} + +export const Keybinds = { parse } + +export function unknownKeys(input: object) { + return Object.keys(input).filter((key) => !KeybindNames.has(key)) +} + export function bindingDefaults(): BindingDefaults { return ({ command, binding }) => { if (binding.desc !== undefined) return diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index b90ce2a41..b4dc02d3b 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -1,8 +1,8 @@ import path from "path" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { unique } from "remeda" -import z from "zod" -import { TuiInfo, TuiOptions } from "./tui-schema" +import { Option, Schema } from "effect" +import { DiffStyle, ScrollAcceleration, ScrollSpeed } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" @@ -13,16 +13,11 @@ const log = Log.create({ service: "tui.migrate" }) const TUI_SCHEMA_URL = "https://opencode.ai/tui.json" -const LegacyTheme = TuiInfo.shape.theme.optional() -const LegacyRecord = z.record(z.string(), z.unknown()).optional() - -const TuiLegacy = z - .object({ - scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined), - scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined), - diff_style: TuiOptions.shape.diff_style.catch(undefined), - }) - .strip() +const decodeTheme = Schema.decodeUnknownOption(Schema.String) +const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) +const decodeScrollSpeed = Schema.decodeUnknownOption(ScrollSpeed) +const decodeScrollAcceleration = Schema.decodeUnknownOption(ScrollAcceleration) +const decodeDiffStyle = Schema.decodeUnknownOption(DiffStyle) interface MigrateInput { cwd: string @@ -46,13 +41,13 @@ export async function migrateTuiConfig(input: MigrateInput) { const data = parseJsonc(source, errors, { allowTrailingComma: true }) if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue - const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined) - const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined) - const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined) + const theme = decodeTheme("theme" in data ? data.theme : undefined) + const keybinds = decodeRecord("keybinds" in data ? data.keybinds : undefined) + const legacyTui = decodeRecord("tui" in data ? data.tui : undefined) const extracted = { - theme: theme.success ? theme.data : undefined, - keybinds: keybinds.success ? keybinds.data : undefined, - tui: legacyTui.success ? legacyTui.data : undefined, + theme: Option.getOrUndefined(theme), + keybinds: Option.getOrUndefined(keybinds), + tui: Option.getOrUndefined(legacyTui), } const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue @@ -85,16 +80,23 @@ export async function migrateTuiConfig(input: MigrateInput) { } } -function normalizeTui(data: Record) { - const parsed = TuiLegacy.parse(data) - if ( - parsed.scroll_speed === undefined && +function normalizeTui(data: Record): + | { + scroll_speed: number | undefined + scroll_acceleration: { enabled: boolean } | undefined + diff_style: "auto" | "stacked" | undefined + } + | undefined { + const parsed = { + scroll_speed: Option.getOrUndefined(decodeScrollSpeed(data.scroll_speed)), + scroll_acceleration: Option.getOrUndefined(decodeScrollAcceleration(data.scroll_acceleration)), + diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)), + } + return parsed.scroll_speed === undefined && parsed.diff_style === undefined && parsed.scroll_acceleration === undefined - ) { - return - } - return parsed + ? undefined + : parsed } async function backupAndStripLegacy(file: string, source: string) { diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index d08836e1d..2c99f2a5e 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,35 +1,78 @@ -import z from "zod" import { ConfigPlugin } from "@/config/plugin" import { TuiKeybind } from "./keybind" +import { Schema } from "effect" +import { isRecord } from "@/util/record" +import { Filesystem } from "@/util/filesystem" +import { TuiAttentionSoundNames, type TuiAttentionSoundName } from "@opencode-ai/plugin/tui" + +export type TuiAttentionSoundPaths = Partial> + +export function isAttentionSoundName(value: string): value is TuiAttentionSoundName { + return TuiAttentionSoundNames.includes(value as TuiAttentionSoundName) +} + +export function resolveAttentionSoundPaths( + root: string, + sounds: unknown, + options?: { trim?: boolean }, +): TuiAttentionSoundPaths { + if (!isRecord(sounds)) return {} + return Object.fromEntries( + Object.entries(sounds).flatMap(([name, file]) => { + if (!isAttentionSoundName(name)) return [] + if (typeof file !== "string") return [] + const value = options?.trim ? file.trim() : file + if (!value) return [] + return [[name, Filesystem.resolveFilePath(root, value)]] + }), + ) +} export const KeymapLeaderTimeoutDefault = 2000 -const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds") - -export const TuiOptions = z.object({ - leader_timeout: KeymapLeaderTimeout.optional(), - scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), - scroll_acceleration: z - .object({ - enabled: z.boolean().describe("Enable scroll acceleration"), - }) - .optional() - .describe("Scroll acceleration settings"), - diff_style: z - .enum(["auto", "stacked"]) - .optional() - .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), - mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"), +const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({ + description: "Leader key timeout in milliseconds", }) -export const TuiInfo = z - .object({ - $schema: z.string().optional(), - theme: z.string().optional(), - keybinds: TuiKeybind.KeybindOverrides.optional(), - plugin: ConfigPlugin.Spec.zod.array().optional(), - plugin_enabled: z.record(z.string(), z.boolean()).optional(), - }) - .extend(TuiOptions.shape) - .strict() +const TuiAttentionSounds = Schema.Struct({ + default: Schema.optional(Schema.String), + question: Schema.optional(Schema.String), + permission: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + done: Schema.optional(Schema.String), + subagent_done: Schema.optional(Schema.String), +}) -export const TuiJsonSchemaInfo = TuiInfo +export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001)) + +export const ScrollAcceleration = Schema.Struct({ + enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }), +}).annotate({ description: "Scroll acceleration settings" }) + +export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({ + description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", +}) + +export const Attention = Schema.Struct({ + enabled: Schema.optional(Schema.Boolean), + notifications: Schema.optional(Schema.Boolean), + sound: Schema.optional(Schema.Boolean), + volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))), + sound_pack: Schema.optional(Schema.String), + sounds: Schema.optional(TuiAttentionSounds), +}).annotate({ description: "Attention notification and sound settings" }) + +export const TuiInfo = Schema.Struct({ + $schema: Schema.optional(Schema.String), + theme: Schema.optional(Schema.String), + keybinds: Schema.optional(TuiKeybind.KeybindOverrides), + plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)), + plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + leader_timeout: Schema.optional(KeymapLeaderTimeout), + attention: Schema.optional(Attention), + scroll_speed: Schema.optional(ScrollSpeed).annotate({ + description: "TUI scroll speed", + }), + scroll_acceleration: Schema.optional(ScrollAcceleration), + diff_style: Schema.optional(DiffStyle), + mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), +}) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 14d991816..562b369db 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,13 +1,14 @@ export * as TuiConfig from "./tui" -import type z from "zod" +import path from "path" import { createBindingLookup } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" -import { Context, Effect, Fiber, Layer } from "effect" +import { Context, Effect, Fiber, Layer, Schema } from "effect" import { ConfigParse } from "@/config/parse" +import { InvalidError } from "@/config/error" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" -import { KeymapLeaderTimeoutDefault, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" +import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" @@ -21,19 +22,28 @@ import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" +import type { DeepMutable } from "@opencode-ai/core/schema" +import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui" const log = Log.create({ service: "tui.config" }) export const Info = TuiInfo -export const JsonSchemaInfo = TuiJsonSchemaInfo -export type Info = z.output +export type Info = DeepMutable> type Acc = { result: Info plugin_origins: ConfigPlugin.Origin[] } -export type Resolved = Omit & { +export type Resolved = Omit & { + attention: { + enabled: boolean + notifications: boolean + sound: boolean + volume: number + sound_pack: string + sounds: Partial> + } keybinds: TuiKeybind.BindingLookupView leader_timeout: number // Internal resolved plugin list used by runtime loading. @@ -91,10 +101,29 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: if (!isRecord(data)) return {} as Info // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // (mirroring the old opencode.json shape) still get their settings applied. - const validated = ConfigParse.schema(Info, normalize(data), configFilepath) + const normalized = normalize(data) + if (isRecord(normalized.keybinds)) { + const invalid = TuiKeybind.unknownKeys(normalized.keybinds) + if (invalid.length) { + throw new InvalidError({ + path: configFilepath, + message: `Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`, + }) + } + } + const parsed = ConfigParse.schema(Info, normalized, configFilepath) + const validated = parsed.attention?.sounds + ? { + ...parsed, + attention: { + ...parsed.attention, + sounds: resolveAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds), + }, + } + : parsed return yield* resolvePlugins(validated, configFilepath) }).pipe( - // catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema + // catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation // can sync-throw — those become defects, which orElseSucceed wouldn't catch. Effect.catchCause((cause) => Effect.sync(() => { @@ -177,18 +206,24 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: } } - const keybinds = { ...(acc.result.keybinds ?? {}) } + const keybinds = { ...acc.result.keybinds } if (process.platform === "win32") { // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. keybinds.terminal_suspend = "none" - keybinds.input_undo ??= unique([ - "ctrl+z", - ...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","), - ]).join(",") + const inputUndo = TuiKeybind.defaultValue("input_undo") + keybinds.input_undo ??= unique(["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]).join(",") } - const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds) + const parsedKeybinds = TuiKeybind.parse(keybinds) const result: Resolved = { ...acc.result, + attention: { + enabled: acc.result.attention?.enabled ?? false, + notifications: acc.result.attention?.notifications ?? true, + sound: acc.result.attention?.sound ?? true, + volume: acc.result.attention?.volume ?? 0.4, + sound_pack: acc.result.attention?.sound_pack ?? "opencode.default", + sounds: acc.result.attention?.sounds ?? {}, + }, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { commandMap: TuiKeybind.CommandMap, bindingDefaults: TuiKeybind.bindingDefaults(), diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 6805f0b66..611db406b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -1,33 +1,36 @@ import { Database } from "bun:sqlite" import os from "node:os" import path from "node:path" -import z from "zod" +import { Option, Schema } from "effect" import { Filesystem } from "@/util/filesystem" import type { EditorSelection } from "./editor" -const ZedEditorRowSchema = z.object({ - item_kind: z.string(), - editor_id: z.number().nullable(), - workspace_id: z.number(), - workspace_paths: z.string().nullable(), - timestamp: z.string(), - buffer_path: z.string().nullable(), +const ZedEditorRowSchema = Schema.Struct({ + item_kind: Schema.String, + editor_id: Schema.NullOr(Schema.Number), + workspace_id: Schema.Number, + workspace_paths: Schema.NullOr(Schema.String), + timestamp: Schema.String, + buffer_path: Schema.NullOr(Schema.String), }) -const ZedSelectionRowSchema = z.object({ - selection_start: z.number().nullable(), - selection_end: z.number().nullable(), +const ZedSelectionRowSchema = Schema.Struct({ + selection_start: Schema.NullOr(Schema.Number), + selection_end: Schema.NullOr(Schema.Number), }) -const ZedEditorContentsSchema = z.object({ - contents: z.string().nullable(), +const ZedEditorContentsSchema = Schema.Struct({ + contents: Schema.NullOr(Schema.String), }) +const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema) +const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema) +const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema) + const utf8 = new TextEncoder() -type ZedEditorRow = z.infer +type ZedEditorRow = Schema.Schema.Type type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number } -type ZedSelectionRow = z.infer export type ZedSelectionResult = | { type: "selection"; selection: EditorSelection } @@ -107,8 +110,8 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { .all() const rows = raw.flatMap((row) => { - const parsed = ZedEditorRowSchema.safeParse(row) - return parsed.success ? [parsed.data] : [] + const parsed = decodeZedEditorRow(row) + return Option.isSome(parsed) ? [parsed.value] : [] }) if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const } @@ -143,8 +146,8 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) { .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id }) const selections = raw.flatMap((selection) => { - const parsed = ZedSelectionRowSchema.safeParse(selection) - return parsed.success ? [parsed.data] : [] + const parsed = decodeZedSelectionRow(selection) + return Option.isSome(parsed) ? [parsed.value] : [] }) if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const } @@ -160,7 +163,7 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { let db: Database | undefined try { db = new Database(dbPath, { readonly: true }) - const parsed = ZedEditorContentsSchema.safeParse( + const parsed = decodeZedEditorContents( db .query( `select contents @@ -169,8 +172,8 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { ) .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }), ) - if (!parsed.success) return { type: "unavailable" as const } - return { type: "contents" as const, contents: parsed.data.contents } + if (Option.isNone(parsed)) return { type: "unavailable" as const } + return { type: "contents" as const, contents: parsed.value.contents } } catch { return { type: "unavailable" as const } } finally { diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index 6d9e04cf8..ea7fd5810 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -3,92 +3,102 @@ import os from "node:os" import path from "node:path" import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" -import z from "zod" +import { Option, Schema, SchemaGetter } from "effect" import { isRecord } from "@/util/record" import { createSimpleContext } from "./helper" import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" const MCP_PROTOCOL_VERSION = "2025-11-25" -const JsonRpcMessageSchema = z.object({ - id: z.union([z.number(), z.string(), z.null()]).optional(), - method: z.string().optional(), - params: z.unknown().optional(), - result: z.unknown().optional(), - error: z - .object({ - code: z.number().optional(), - message: z.string().optional(), - }) - .optional(), +const JsonRpcMessageSchema = Schema.Struct({ + id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])), + method: Schema.optional(Schema.String), + params: Schema.optional(Schema.Unknown), + result: Schema.optional(Schema.Unknown), + error: Schema.optional( + Schema.Struct({ + code: Schema.optional(Schema.Number), + message: Schema.optional(Schema.String), + }), + ), }) -const PositionSchema = z.object({ - line: z.number(), - character: z.number(), +const PositionSchema = Schema.Struct({ + line: Schema.Number, + character: Schema.Number, }) -const EditorSelectionRangeSchema = z.object({ - text: z.string(), - selection: z.object({ +const EditorSelectionRangeSchema = Schema.Struct({ + text: Schema.String, + selection: Schema.Struct({ start: PositionSchema, end: PositionSchema, }), }) -const EditorSelectionSchema = z - .union([ - z.object({ - filePath: z.string(), - source: z.enum(["websocket", "zed"]).optional(), - ranges: z.array(EditorSelectionRangeSchema).min(1), - }), - z.object({ - text: z.string(), - filePath: z.string(), - source: z.enum(["websocket", "zed"]).optional(), - selection: z.object({ - start: PositionSchema, - end: PositionSchema, - }), - }), - ]) - .transform((value) => - "ranges" in value - ? value - : { - filePath: value.filePath, - source: value.source, - ranges: [ - { - text: value.text, - selection: value.selection, - }, - ], - }, - ) - -const EditorMentionSchema = z.object({ - filePath: z.string(), - lineStart: z.number(), - lineEnd: z.number(), +const EditorSelectionRangesSchema = Schema.Struct({ + filePath: Schema.String, + source: Schema.optional(Schema.Literals(["websocket", "zed"])), + ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))), }) -const EditorServerInfoSchema = z.object({ - protocolVersion: z.string().optional(), - serverInfo: z - .object({ - name: z.string().optional(), - version: z.string().optional(), - }) - .optional(), +const EditorSelectionSchema = Schema.Union([ + EditorSelectionRangesSchema, + Schema.Struct({ + text: Schema.String, + filePath: Schema.String, + source: Schema.optional(Schema.Literals(["websocket", "zed"])), + selection: Schema.Struct({ + start: PositionSchema, + end: PositionSchema, + }), + }), +]).pipe( + Schema.decodeTo(EditorSelectionRangesSchema, { + decode: SchemaGetter.transform((value) => + "ranges" in value + ? value + : { + filePath: value.filePath, + source: value.source, + ranges: [ + { + text: value.text, + selection: value.selection, + }, + ], + }, + ), + encode: SchemaGetter.passthrough({ strict: false }), + }), +) + +const EditorMentionSchema = Schema.Struct({ + filePath: Schema.String, + lineStart: Schema.Number, + lineEnd: Schema.Number, }) -type JsonRpcMessage = z.infer -export type EditorSelection = z.infer -export type EditorMention = z.infer +const EditorServerInfoSchema = Schema.Struct({ + protocolVersion: Schema.optional(Schema.String), + serverInfo: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + }), + ), +}) + +const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema) +const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema) +const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema) +const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema) + +type JsonRpcMessage = Schema.Schema.Type +export type EditorSelection = Schema.Schema.Type +export type EditorMention = Schema.Schema.Type export type EditorLabelState = "pending" | "sent" | "none" -type EditorServerInfo = z.infer +type EditorServerInfo = Schema.Schema.Type type EditorConnection = { url: string @@ -214,16 +224,15 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const message = parseMessage(event.data) if (!message) return - const selection = - message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined - if (selection?.success) { - setSelection({ ...selection.data, source: "websocket" }) + const selection = message.method === "selection_changed" ? decodeEditorSelection(message.params) : Option.none() + if (Option.isSome(selection)) { + setSelection({ ...selection.value, source: "websocket" }) return } - const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined - if (mention?.success) { - mentionListeners.forEach((listener) => listener(mention.data)) + const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none() + if (Option.isSome(mention)) { + mentionListeners.forEach((listener) => listener(mention.value)) return } @@ -235,9 +244,9 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create pending.delete(message.id) if (message.error) return - const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined - if (initialize?.success) { - setStore("server", initialize.data) + const initialize = method === "initialize" ? decodeEditorServerInfo(message.result) : Option.none() + if (Option.isSome(initialize)) { + setStore("server", initialize.value) send({ method: "notifications/initialized" }) return } @@ -447,7 +456,7 @@ function parseMessage(value: unknown) { if (typeof value !== "string") return try { - return JsonRpcMessageSchema.parse(JSON.parse(value)) + return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value))) } catch { return } diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index 156f9c947..5d814ecdc 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -2,39 +2,33 @@ import type { Event } from "@opencode-ai/sdk/v2" import { useProject } from "./project" import { useSDK } from "./sdk" +type EventMetadata = { + workspace: string | undefined +} + export function useEvent() { const project = useProject() const sdk = useSDK() - function subscribe(handler: (event: Event) => void) { + function subscribe(handler: (event: Event, metadata: EventMetadata) => void) { return sdk.event.on("event", (event) => { if (event.payload.type === "sync") { return } - // Special hack for truly global events - if (event.directory === "global") { - handler(event.payload) - } - - if (project.workspace.current()) { - if (event.workspace === project.workspace.current()) { - handler(event.payload) - } - - return - } - - if (event.directory === project.instance.directory()) { - handler(event.payload) + if (event.directory === "global" || event.project === project.project()) { + handler(event.payload, { workspace: event.workspace }) } }) } - function on(type: T, handler: (event: Extract) => void) { - return subscribe((event) => { + function on( + type: T, + handler: (event: Extract, metadata: EventMetadata) => void, + ) { + return subscribe((event: Event, metadata: EventMetadata) => { if (event.type !== type) return - handler(event as Extract) + handler(event as Extract, metadata) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 2958b573d..fc2226315 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,11 +1,14 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -import { batch, createEffect, createMemo } from "solid-js" +import { batch, createEffect, createMemo, on } from "solid-js" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" +import { useRoute } from "@tui/context/route" +import { useEvent } from "@tui/context/event" import { uniqueBy } from "remeda" import path from "path" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" import { iife } from "@/util/iife" import { useToast } from "../ui/toast" import { useArgs } from "./args" @@ -380,6 +383,192 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) + const session = iife(() => { + const [sessionStore, setSessionStore] = createStore<{ + ready: boolean + pinned: string[] + dismissedRecent: string[] + recentOrder: string[] + }>({ + ready: false, + pinned: [], + dismissedRecent: [], + recentOrder: [], + }) + + const filePath = path.join(Global.Path.state, "session.json") + const state = { + pending: false, + } + + function save() { + if (!sessionStore.ready) { + state.pending = true + return + } + state.pending = false + void Filesystem.writeJson(filePath, { + pinned: sessionStore.pinned, + dismissedRecent: sessionStore.dismissedRecent, + recentOrder: sessionStore.recentOrder, + }) + } + + Filesystem.readJson(filePath) + .then((x: any) => { + if (Array.isArray(x.pinned)) setSessionStore("pinned", x.pinned) + if (Array.isArray(x.dismissedRecent)) setSessionStore("dismissedRecent", x.dismissedRecent) + if (Array.isArray(x.recentOrder)) setSessionStore("recentOrder", x.recentOrder) + }) + .catch(() => {}) + .finally(() => { + setSessionStore("ready", true) + if (state.pending) save() + }) + + const route = useRoute() + const event = useEvent() + let cycling = false + + const slots = createMemo(() => { + const rootSessions = sync.data.session.filter((x) => x.parentID === undefined) + const existing = new Set(rootSessions.map((x) => x.id)) + const dismissed = new Set(sessionStore.dismissedRecent) + const pins = sessionStore.pinned.filter((id) => existing.has(id)) + const pinnedSet = new Set(pins) + const recent = rootSessions + .filter((x) => !pinnedSet.has(x.id) && !dismissed.has(x.id)) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((x) => x.id) + return [...pins, ...recent].slice(0, 9) + }) + + function prune(sessionID: string) { + batch(() => { + if (sessionStore.pinned.includes(sessionID)) { + setSessionStore( + "pinned", + sessionStore.pinned.filter((x) => x !== sessionID), + ) + } + if (sessionStore.dismissedRecent.includes(sessionID)) { + setSessionStore( + "dismissedRecent", + sessionStore.dismissedRecent.filter((x) => x !== sessionID), + ) + } + if (sessionStore.recentOrder.includes(sessionID)) { + setSessionStore( + "recentOrder", + sessionStore.recentOrder.filter((x) => x !== sessionID), + ) + } + save() + }) + } + + event.on("session.deleted", (evt) => { + prune(evt.properties.info.id) + }) + + if (Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING) { + createEffect( + on( + () => (sessionStore.ready && route.data.type === "session" ? route.data.sessionID : undefined), + (sessionID) => { + if (!sessionID) return + if (cycling) { + cycling = false + return + } + const filtered = sessionStore.recentOrder.filter((x) => x !== sessionID) + const next = [sessionID, ...filtered].slice(0, 20) + setSessionStore("recentOrder", next) + save() + }, + ), + ) + } + + return { + get ready() { + return sessionStore.ready + }, + pinned() { + return sessionStore.pinned + }, + dismissedRecent() { + return sessionStore.dismissedRecent + }, + recentOrder() { + return sessionStore.recentOrder + }, + slots, + isPinned(sessionID: string) { + return sessionStore.pinned.includes(sessionID) + }, + isDismissed(sessionID: string) { + return sessionStore.dismissedRecent.includes(sessionID) + }, + togglePin(sessionID: string) { + batch(() => { + const exists = sessionStore.pinned.includes(sessionID) + const next = exists + ? sessionStore.pinned.filter((x) => x !== sessionID) + : [sessionID, ...sessionStore.pinned] + setSessionStore("pinned", next) + save() + }) + }, + toggleRecent(sessionID: string) { + batch(() => { + const exists = sessionStore.dismissedRecent.includes(sessionID) + const next = exists + ? sessionStore.dismissedRecent.filter((x) => x !== sessionID) + : [sessionID, ...sessionStore.dismissedRecent] + setSessionStore("dismissedRecent", next) + save() + }) + }, + quickSwitch(slot: number) { + const target = slots()[slot - 1] + if (!target) return + if (route.data.type === "session" && route.data.sessionID === target) return + route.navigate({ type: "session", sessionID: target }) + }, + cycleRecent(direction: 1 | -1) { + if (route.data.type !== "session") { + toast.show({ + variant: "info", + message: "Open a session first to cycle between recent sessions", + duration: 3000, + }) + return + } + const current = route.data.sessionID + const order = sessionStore.recentOrder.filter((id) => + sync.data.session.some((s) => s.id === id && s.parentID === undefined), + ) + if (order.length < 2) { + toast.show({ + variant: "info", + message: "No other recent sessions to cycle to", + duration: 3000, + }) + return + } + const index = order.indexOf(current) + if (index === -1) return + const next = index + direction + if (next < 0 || next >= order.length) return + const target = order[next] + if (!target || target === current) return + cycling = true + route.navigate({ type: "session", sessionID: target }) + }, + } + }) + const mcp = { isEnabled(name: string) { const status = sync.data.mcp[name] @@ -412,6 +601,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, mcp, + session, } return result }, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0d4cb2e6e..9f8a384f7 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -113,7 +113,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const kv = useKV() const fullSyncedSessions = new Set() - let syncedWorkspace = project.workspace.current() function sessionListQuery(): { scope?: "project"; path?: string } { if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } @@ -131,7 +130,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } - event.subscribe((event) => { + event.subscribe((event, { workspace }) => { switch (event.type) { case "server.instance.disposed": void bootstrap() @@ -346,7 +345,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "message.part.removed": { const parts = store.part[event.properties.messageID] const result = Binary.search(parts, event.properties.partID, (p) => p.id) - if (result.found) + if (result.found) { setStore( "part", event.properties.messageID, @@ -354,6 +353,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ draft.splice(result.index, 1) }), ) + } break } @@ -364,7 +364,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "vcs.branch.updated": { - setStore("vcs", { branch: event.properties.branch }) + if (workspace === project.workspace.current()) { + setStore("vcs", { branch: event.properties.branch }) + } break } } @@ -376,10 +378,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ async function bootstrap(input: { fatal?: boolean } = {}) { const fatal = input.fatal ?? true const workspace = project.workspace.current() - if (workspace !== syncedWorkspace) { - fullSyncedSessions.clear() - syncedWorkspace = workspace - } const projectPromise = project.sync() const sessionListPromise = projectPromise.then(() => listSessions()) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index c7a7b211f..8c50914df 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -1,10 +1,52 @@ -import { createMemo, For } from "solid-js" +import type { TuiPluginApi } from "@opencode-ai/plugin/tui" +import { createMemo, For, type Accessor } from "solid-js" import { DEFAULT_THEMES, useTheme } from "@tui/context/theme" +import { Flag } from "@opencode-ai/core/flag/flag" +import { useCommandShortcut } from "../../keymap" const themeCount = Object.keys(DEFAULT_THEMES).length -const themeTip = `Use {highlight}/themes{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes` type TipPart = { text: string; highlight: boolean } +type TipShortcut = Accessor +type Shortcuts = { + agentCycle: TipShortcut + childFirst: TipShortcut + childNext: TipShortcut + childPrevious: TipShortcut + commandList: TipShortcut + editorOpen: TipShortcut + helpShow: TipShortcut + inputClear: TipShortcut + inputNewline: TipShortcut + inputPaste: TipShortcut + inputUndo: TipShortcut + leader: TipShortcut + messagesCopy: TipShortcut + messagesFirst: TipShortcut + messagesLast: TipShortcut + messagesPageDown: TipShortcut + messagesPageUp: TipShortcut + messagesToggleConceal: TipShortcut + modelCycleRecent: TipShortcut + modelList: TipShortcut + sessionCycleRecent: TipShortcut + sessionCycleRecentReverse: TipShortcut + sessionExport: TipShortcut + sessionInterrupt: TipShortcut + sessionList: TipShortcut + sessionNew: TipShortcut + sessionParent: TipShortcut + sessionPinToggle: TipShortcut + sessionQuickSwitch1: TipShortcut + sessionQuickSwitch9: TipShortcut + sessionSidebarToggle: TipShortcut + sessionTimeline: TipShortcut + sessionToggleRecent: TipShortcut + statusView: TipShortcut + terminalSuspend: TipShortcut + themeList: TipShortcut +} +type Tip = string | ((shortcuts: Shortcuts) => string | undefined) function parse(tip: string): TipPart[] { const parts: TipPart[] = [] @@ -32,17 +74,86 @@ function parse(tip: string): TipPart[] { const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding" -export function Tips(props: { connected?: boolean }) { +function shortcutText(value: string) { + return `{highlight}${value}{/highlight}` +} + +function commandText(command: string, shortcut: string) { + if (!shortcut) return shortcutText(command) + return `${shortcutText(command)} or ${shortcutText(shortcut)}` +} + +function press(shortcut: string, text: string) { + if (!shortcut) return undefined + return `Press ${shortcutText(shortcut)} ${text}` +} + +function configShortcut(api: TuiPluginApi, command: string): TipShortcut { + return () => + api.tuiConfig.keybinds + .get(command) + .map((binding) => api.keys.formatSequence(Array.from(api.keymap.parseKeySequence(binding.key)))) + .filter(Boolean) + .join(", ") +} + +export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { const theme = useTheme().theme - const randomTip = TIPS[Math.floor(Math.random() * TIPS.length)] - const parts = createMemo(() => parse(props.connected === false ? NO_MODELS_TIP : randomTip)) + const tipOffset = Math.random() + const shortcuts: Shortcuts = { + agentCycle: useCommandShortcut("agent.cycle"), + childFirst: configShortcut(props.api, "session.child.first"), + childNext: configShortcut(props.api, "session.child.next"), + childPrevious: configShortcut(props.api, "session.child.previous"), + commandList: useCommandShortcut("command.palette.show"), + editorOpen: useCommandShortcut("prompt.editor"), + helpShow: useCommandShortcut("help.show"), + inputClear: useCommandShortcut("prompt.clear"), + inputNewline: useCommandShortcut("input.newline"), + inputPaste: useCommandShortcut("prompt.paste"), + inputUndo: useCommandShortcut("input.undo"), + leader: configShortcut(props.api, "leader"), + messagesCopy: configShortcut(props.api, "messages.copy"), + messagesFirst: configShortcut(props.api, "session.first"), + messagesLast: configShortcut(props.api, "session.last"), + messagesPageDown: configShortcut(props.api, "session.page.down"), + messagesPageUp: configShortcut(props.api, "session.page.up"), + messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"), + modelCycleRecent: useCommandShortcut("model.cycle_recent"), + modelList: useCommandShortcut("model.list"), + sessionCycleRecent: useCommandShortcut("session.cycle_recent"), + sessionCycleRecentReverse: useCommandShortcut("session.cycle_recent_reverse"), + sessionExport: configShortcut(props.api, "session.export"), + sessionInterrupt: configShortcut(props.api, "session.interrupt"), + sessionList: useCommandShortcut("session.list"), + sessionNew: useCommandShortcut("session.new"), + sessionParent: configShortcut(props.api, "session.parent"), + sessionPinToggle: configShortcut(props.api, "session.pin.toggle"), + sessionQuickSwitch1: useCommandShortcut("session.quick_switch.1"), + sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"), + sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"), + sessionTimeline: configShortcut(props.api, "session.timeline"), + sessionToggleRecent: configShortcut(props.api, "session.toggle.recent"), + statusView: useCommandShortcut("opencode.status"), + terminalSuspend: useCommandShortcut("terminal.suspend"), + themeList: useCommandShortcut("theme.switch"), + } + const tip = createMemo(() => { + if (props.connected === false) return NO_MODELS_TIP + const tips = TIPS.flatMap((item) => { + const value = typeof item === "string" ? item : item(shortcuts) + return value ? [value] : [] + }) + return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP + }) + const parts = createMemo(() => parse(tip())) return ( ● Tip{" "} - + {(part) => {part.text}} @@ -51,38 +162,66 @@ export function Tips(props: { connected?: boolean }) { ) } -const TIPS = [ +const TIPS: Tip[] = [ "Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files", "Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight})", - "Press {highlight}Tab{/highlight} to cycle between Build and Plan agents", + (shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"), "Use {highlight}/undo{/highlight} to revert the last message and file changes", "Use {highlight}/redo{/highlight} to restore previously undone messages and file changes", "Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai", "Drag and drop images or PDFs into the terminal to add them as context", - "Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard into the prompt", - "Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor", + (shortcuts) => press(shortcuts.inputPaste(), "to paste images from your clipboard into the prompt"), + (shortcuts) => `Use ${commandText("/editor", shortcuts.editorOpen())} to compose messages in your external editor`, "Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase", - "Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models", - themeTip, - "Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session", - "Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations", + (shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`, + (shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`, + (shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`, + (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`, + ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING + ? ([ + (shortcuts) => + press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"), + (shortcuts) => + shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() + ? `Pinned and recent sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching` + : undefined, + (shortcuts) => + shortcuts.sessionCycleRecent() && shortcuts.sessionCycleRecentReverse() + ? `Press ${shortcutText(shortcuts.sessionCycleRecent())} / ${shortcutText(shortcuts.sessionCycleRecentReverse())} to cycle through recently visited sessions` + : undefined, + (shortcuts) => + press(shortcuts.sessionToggleRecent(), "in the session list to show or hide a session in the Recent group"), + ] satisfies Tip[]) + : []), "Run {highlight}/compact{/highlight} to summarize long sessions near context limits", - "Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown", - "Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard", - "Press {highlight}Ctrl+P{/highlight} to see all available actions and commands", + (shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`, + (shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"), + (shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"), "Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers", - "The leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions", - "Press {highlight}F2{/highlight} to quickly switch between recently used models", - "Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel", - "Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history", - "Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation", - "Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message", - "Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt", - "Press {highlight}Ctrl+C{/highlight} when typing to clear the input field", - "Press {highlight}Escape{/highlight} to stop the AI mid-response", + (shortcuts) => `The leader key is ${shortcutText(shortcuts.leader())}; combine with other keys for quick actions`, + (shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"), + (shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"), + (shortcuts) => + shortcuts.messagesPageUp() && shortcuts.messagesPageDown() + ? `Use ${shortcutText(shortcuts.messagesPageUp())}/${shortcutText(shortcuts.messagesPageDown())} to navigate through conversation history` + : undefined, + (shortcuts) => press(shortcuts.messagesFirst(), "to jump to the beginning of the conversation"), + (shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"), + (shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"), + (shortcuts) => press(shortcuts.inputClear(), "when typing to clear the input field"), + (shortcuts) => press(shortcuts.sessionInterrupt(), "to stop the AI mid-response"), "Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes", "Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents", - "Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions", + (shortcuts) => { + const items = [ + shortcuts.sessionParent(), + shortcuts.childFirst(), + shortcuts.childPrevious(), + shortcuts.childNext(), + ].filter(Boolean) + if (!items.length) return undefined + return `Use ${items.map(shortcutText).join(" / ")} to move between parent and child sessions` + }, "Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings", "Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config", "Add {highlight}$schema{/highlight} to your config for autocomplete in your editor", @@ -90,22 +229,21 @@ const TIPS = [ "Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section", "Set any keybind to {highlight}none{/highlight} to disable it completely", "Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section", - "OpenCode auto-handles OAuth for remote MCP servers requiring auth", - "Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts", + "Add {highlight}.md{/highlight} files to {highlight}.opencode/commands/{/highlight} to define reusable custom prompts", "Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input", "Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})", - "Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas", + "Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas", "Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools", 'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions', 'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands', 'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing', - "OpenCode auto-formats files using prettier, gofmt, ruff, and more", - 'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting', + 'Set {highlight}"formatter": true{/highlight} in config to enable built-in formatters like prettier, gofmt, and ruff', + 'Set {highlight}"formatter": false{/highlight} in config to disable formatters enabled by another config layer', "Define custom formatter commands with file extensions in config", - "OpenCode uses LSP servers for intelligent code analysis", + 'Set {highlight}"lsp": true{/highlight} in config to enable built-in LSP servers for code analysis', "Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools", "Tool definitions can invoke scripts written in Python, Go, etc", - "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks", + "Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugins/{/highlight} for event hooks", "Use plugins to send OS notifications when sessions complete", "Create a plugin to prevent OpenCode from reading sensitive files", "Use {highlight}opencode run{/highlight} for non-interactive scripting", @@ -124,7 +262,7 @@ const TIPS = [ 'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors', "Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory", "Themes support dark/light variants for both modes", - "Reference ANSI colors 0-255 in custom themes", + "Use numeric xterm color codes 0-255 in custom theme JSON", "Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config", "Use {highlight}{file:path}{/highlight} to include file contents in config values", "Use {highlight}instructions{/highlight} in config to load additional rules files", @@ -140,18 +278,23 @@ const TIPS = [ "Permission {highlight}external_directory{/highlight} protects files outside project", "Run {highlight}opencode debug config{/highlight} to troubleshoot configuration", "Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr", - "Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages", - "Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages", - "Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info", + (shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`, + (shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"), + (shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`, "Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling", - "Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})", + (shortcuts) => + shortcuts.commandList() + ? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})` + : "Toggle username display in chat via the command palette", "Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use", "Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models", "Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing", "Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs", - "Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog", + (shortcuts) => `Use ${commandText("/help", shortcuts.helpShow())} to show the help dialog`, "Use {highlight}/rename{/highlight} to rename the current session", ...(process.platform === "win32" - ? ["Press {highlight}Ctrl+Z{/highlight} to undo changes in your prompt"] - : ["Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell"]), + ? ([(shortcuts) => press(shortcuts.inputUndo(), "to undo changes in your prompt")] satisfies Tip[]) + : ([ + (shortcuts) => press(shortcuts.terminalSuspend(), "to suspend the terminal and return to your shell"), + ] satisfies Tip[])), ] diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx index 69071b1f7..598366c08 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx @@ -24,9 +24,9 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec })) return ( - + - + ) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index b3cf2beb4..405e8c145 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -13,7 +13,8 @@ const money = new Intl.NumberFormat("en-US", { function View(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const msg = createMemo(() => props.api.state.session.messages(props.session_id)) - const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)) + const session = createMemo(() => props.api.state.session.get(props.session_id)) + const cost = createMemo(() => session()?.cost ?? 0) const state = createMemo(() => { const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/notifications.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/notifications.ts new file mode 100644 index 000000000..cda815f5f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/notifications.ts @@ -0,0 +1,94 @@ +import type { Event } from "@opencode-ai/sdk/v2" +import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" + +const id = "internal:notifications" + +type SessionError = Extract["properties"]["error"] + +function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) { + const session = sessionID ? api.state.session.get(sessionID) : undefined + const isSubagent = session?.parentID !== undefined + void api.attention.notify({ + title: session?.title, + message, + notification: isSubagent ? false : { when: "blurred" }, + sound: { name: sound, when: "always" }, + }) +} + +function sessionErrorMessage(error: SessionError) { + if (error?.name === "MessageAbortedError") return "Session aborted" + const data = error?.data + if (data && typeof data === "object" && "message" in data && data.message === "SSE read timed out") { + return "Model stopped responding" + } + return "Session error" +} + +const tui: TuiPlugin = async (api) => { + const active = new Set() + const errored = new Set() + const questions = new Set() + const permissions = new Set() + + api.event.on("question.asked", (event) => { + if (questions.has(event.properties.id)) return + questions.add(event.properties.id) + notify(api, event.properties.sessionID, "Question needs input", "question") + }) + + api.event.on("question.replied", (event) => { + questions.delete(event.properties.requestID) + }) + + api.event.on("question.rejected", (event) => { + questions.delete(event.properties.requestID) + }) + + api.event.on("permission.asked", (event) => { + if (permissions.has(event.properties.id)) return + permissions.add(event.properties.id) + notify(api, event.properties.sessionID, "Permission needs input", "permission") + }) + + api.event.on("permission.replied", (event) => { + permissions.delete(event.properties.requestID) + }) + + api.event.on("session.status", (event) => { + const sessionID = event.properties.sessionID + if (event.properties.status.type === "busy" || event.properties.status.type === "retry") { + active.add(sessionID) + errored.delete(sessionID) + return + } + + if (event.properties.status.type !== "idle") return + if (!active.has(sessionID)) return + active.delete(sessionID) + + if (errored.has(sessionID)) { + errored.delete(sessionID) + return + } + + const session = api.state.session.get(sessionID) + notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") + }) + + api.event.on("session.error", (event) => { + const sessionID = event.properties.sessionID + if (!sessionID) return + if (!active.has(sessionID)) return + errored.add(sessionID) + notify(api, sessionID, sessionErrorMessage(event.properties.error), "error") + }) +} + +const plugin: InternalTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 8b741ccb4..bcf3032ea 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -438,9 +438,6 @@ function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: st - - - @@ -773,15 +770,6 @@ function WebFetch(props: ToolProps) { ) } -function CodeSearch(props: ToolProps) { - return ( - - Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} - {(results) => <>({results()} results)} - - ) -} - function WebSearch(props: ToolProps) { const label = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 54059f4a2..05bfa31d1 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -40,6 +40,7 @@ type Input = { theme: ReturnType toast: ReturnType renderer: TuiPluginApi["renderer"] + attention: TuiPluginApi["attention"] } function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) { @@ -147,6 +148,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { count() { return sync.data.session.length }, + get(sessionID) { + return sync.session.get(sessionID) + }, diff(sessionID) { return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => item.file === undefined ? [] : [{ ...item, file: item.file }], @@ -203,6 +207,7 @@ export function createTuiApi(input: Input): TuiPluginApi { } return { app: appApi(), + attention: input.attention, // Keep deprecated `api.command` working for v1 plugins; remove in v2. command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds), keys: { diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 664b5c1ac..eaa9dfb32 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -7,6 +7,7 @@ import SidebarTodo from "../feature-plugins/sidebar/todo" import SidebarFiles from "../feature-plugins/sidebar/files" import SidebarFooter from "../feature-plugins/sidebar/footer" import PluginManager from "../feature-plugins/system/plugins" +import Notifications from "../feature-plugins/system/notifications" import SessionV2Debug from "../feature-plugins/system/session-v2" import WhichKey from "../feature-plugins/system/which-key" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" @@ -27,6 +28,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ SidebarTodo, SidebarFiles, SidebarFooter, + Notifications, PluginManager, WhichKey, ...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []), diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 64961b20f..4af16d2b8 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -17,7 +17,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import * as Log from "@opencode-ai/core/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" -import { WithInstance } from "@/project/with-instance" +import { resolveAttentionSoundPaths } from "../config/tui-schema" import { readPackageThemes, readPluginId, @@ -52,7 +52,7 @@ type PluginLoad = { id: string module: TuiPluginModule origin: ConfigPlugin.Origin - theme_root: string + plugin_root: string theme_files: string[] } @@ -107,6 +107,7 @@ const ScopedKeymapMethods = new Set([ type RuntimeState = { directory: string api: Api + dispose?: () => void slots: HostSlots plugins: PluginEntry[] plugins_by_id: Map @@ -157,6 +158,37 @@ function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): }) } +function createScopedAttention( + attention: TuiPluginApi["attention"], + scope: PluginScope, + root: string, +): TuiPluginApi["attention"] { + return { + notify(input) { + return attention.notify(input) + }, + soundboard: { + registerPack(pack) { + return scope.track( + attention.soundboard.registerPack({ + ...pack, + sounds: resolveAttentionSoundPaths(root, pack.sounds, { trim: true }), + }), + ) + }, + activate(id, options) { + return attention.soundboard.activate(id, options) + }, + current() { + return attention.soundboard.current() + }, + list() { + return attention.soundboard.list() + }, + }, + } +} + type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" } function runCleanup(fn: () => unknown, ms: number): Promise { @@ -205,8 +237,7 @@ function createThemeInstaller( plugin: PluginEntry, ): TuiTheme["install"] { return async (file) => { - const raw = file.startsWith("file://") ? fileURLToPath(file) : file - const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw) + const src = Filesystem.resolveFilePath(root, file) const name = path.basename(src, path.extname(src)) const source_dir = path.dirname(meta.source) const local_dir = @@ -331,7 +362,7 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad { scope: "global", source: target, }, - theme_root: process.cwd(), + plugin_root: process.cwd(), theme_files: [], } } @@ -353,7 +384,7 @@ async function readThemeFiles(spec: string, pkg?: PluginPackage) { async function syncPluginThemes(plugin: PluginEntry) { if (!plugin.load.theme_files.length) return if (plugin.meta.state === "same") return - const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin) + const install = createThemeInstaller(plugin.load.origin, plugin.load.plugin_root, plugin.load.spec, plugin) for (const file of plugin.load.theme_files) { await install(file).catch((error) => { warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error }) @@ -553,7 +584,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop } const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), { - install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin), + install: createThemeInstaller(load.origin, load.plugin_root, load.spec, plugin), }) const event: TuiPluginApi["event"] = { @@ -577,6 +608,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, + attention: createScopedAttention(api.attention, scope, load.plugin_root), // Keep deprecated `api.command` working for v1 plugins; remove in v2. command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, @@ -683,7 +715,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P id, module: mod, origin, - theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), + plugin_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), theme_files, } }, @@ -710,7 +742,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P id, module: EMPTY_TUI, origin, - theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), + plugin_root: loaded.pkg?.dir ?? resolveRoot(loaded.target), theme_files, } }, @@ -838,10 +870,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { state.pending.delete(spec) return true } - const ready = await WithInstance.provide({ - directory: state.directory, - fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), - }).catch((error) => { + const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()).catch((error) => { fail("failed to add tui plugin", { path: next, error }) return [] as PluginLoad[] }) @@ -971,7 +1000,7 @@ let loaded: Promise | undefined let runtime: RuntimeState | undefined export const Slot = View -export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) { +export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved; dispose?: () => void }) { const cwd = process.cwd() if (loaded) { if (dir !== cwd) { @@ -1018,15 +1047,17 @@ export async function dispose() { for (const plugin of queue) { await deactivatePluginEntry(state, plugin, false) } + state.dispose?.() } -async function load(input: { api: Api; config: TuiConfig.Resolved }) { +async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void }) { const { api, config } = input const cwd = process.cwd() const slots = setupSlots(api) const next: RuntimeState = { directory: cwd, api, + dispose: input.dispose, slots, plugins: [], plugins_by_id: new Map(), @@ -1034,42 +1065,37 @@ async function load(input: { api: Api; config: TuiConfig.Resolved }) { } runtime = next try { - await WithInstance.provide({ - directory: cwd, - fn: async () => { - const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) - if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { - log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) - } + const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) + if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { + log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) + } - for (const item of INTERNAL_TUI_PLUGINS) { - log.info("loading internal tui plugin", { id: item.id }) - const entry = loadInternalPlugin(item) - const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) - addPluginEntry(next, { - id: entry.id, - load: entry, - meta, - themes: {}, - plugin: entry.module.tui, - enabled: item.enabled ?? true, - }) - } + for (const item of INTERNAL_TUI_PLUGINS) { + log.info("loading internal tui plugin", { id: item.id }) + const entry = loadInternalPlugin(item) + const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) + addPluginEntry(next, { + id: entry.id, + load: entry, + meta, + themes: {}, + plugin: entry.module.tui, + enabled: item.enabled ?? true, + }) + } - const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) - await addExternalPluginEntries(next, ready) + const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) + await addExternalPluginEntries(next, ready) - applyInitialPluginEnabledState(next, config) - for (const plugin of next.plugins) { - if (!plugin.enabled) continue - // Keep plugin execution sequential for deterministic side effects: - // command registration order affects keybind/command precedence, - // route registration is last-wins when ids collide, - // and hook chains rely on stable plugin ordering. - await activatePluginEntry(next, plugin, false) - } - }, - }) + applyInitialPluginEnabledState(next, config) + for (const plugin of next.plugins) { + if (!plugin.enabled) continue + // Keep plugin execution sequential for deterministic side effects: + // command registration order affects keybind/command precedence, + // route registration is last-wins when ids collide, + // and hook chains rely on stable plugin ordering. + await activatePluginEntry(next, plugin, false) + } } catch (error) { fail("failed to load tui plugins", { directory: cwd, error }) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b2ee3af62..95d1b072f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -10,6 +10,7 @@ import { onMount, Show, Switch, + untrack, useContext, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -242,7 +243,7 @@ export function Session() { createEffect(() => { const sessionID = route.sessionID void (async () => { - const previousWorkspace = project.workspace.current() + const previousWorkspace = untrack(() => project.workspace.current()) const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) if (!result.data) { toast.show({ @@ -1984,11 +1985,11 @@ function WebFetch(props: ToolProps) { } function WebSearch(props: ToolProps) { - const metadata = props.metadata as { numResults?: number; provider?: unknown } + const metadata = () => props.metadata as { numResults?: number; provider?: unknown } return ( - {webSearchProviderLabel(metadata.provider)} "{props.input.query}"{" "} - ({metadata.numResults} results) + {webSearchProviderLabel(metadata().provider)} "{props.input.query}"{" "} + ({metadata().numResults} results) ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index 2a6813ffb..f4a458b63 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -42,7 +42,7 @@ export function SubagentFooter() { const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) + const cost = session()?.cost ?? 0 const money = new Intl.NumberFormat("en-US", { style: "currency", diff --git a/packages/opencode/src/cli/cmd/tui/util/audio.ts b/packages/opencode/src/cli/cmd/tui/util/audio.ts new file mode 100644 index 000000000..7d7c3d5a4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/audio.ts @@ -0,0 +1,58 @@ +import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "tui.audio" }) + +let audio: Audio | null | undefined +const sounds = new Map>() + +function getAudio() { + if (audio !== undefined) return audio + try { + const next = Audio.create({ autoStart: false }) + next.on("error", (error: Error, context: AudioErrorContext) => { + log.debug("tui audio error", { error, context }) + }) + audio = next + return next + } catch (error) { + log.debug("failed to create tui audio", { error }) + audio = null + return null + } +} + +export function loadSoundFile(file: string) { + const current = getAudio() + if (!current) return Promise.resolve(null) + const cached = sounds.get(file) + if (cached) return cached + const task = Bun.file(file) + .bytes() + .then((bytes) => current.loadSound(bytes)) + .catch((error) => { + log.debug("failed to load tui sound", { file, error }) + return null + }) + sounds.set(file, task) + return task +} + +export function play(sound: AudioSound, options?: AudioPlayOptions) { + const current = getAudio() + if (!current) return null + if (!current.isStarted() && !current.start()) return null + return current.play(sound, options) +} + +export function stopVoice(voice: AudioVoice) { + return audio?.stopVoice(voice) ?? false +} + +export function dispose() { + audio?.dispose() + audio = undefined + sounds.clear() +} + +export * as TuiAudio from "./audio" diff --git a/packages/opencode/src/cli/cmd/tui/util/sound.ts b/packages/opencode/src/cli/cmd/tui/util/sound.ts deleted file mode 100644 index df8b4dc2d..000000000 --- a/packages/opencode/src/cli/cmd/tui/util/sound.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Player } from "cli-sound" -import { mkdirSync } from "node:fs" -import { tmpdir } from "node:os" -import { basename, join } from "node:path" -import { Process } from "@/util/process" -import { which } from "@/util/which" -import pulseA from "../asset/pulse-a.wav" with { type: "file" } -import pulseB from "../asset/pulse-b.wav" with { type: "file" } -import pulseC from "../asset/pulse-c.wav" with { type: "file" } -import charge from "../asset/charge.wav" with { type: "file" } - -const FILE = [pulseA, pulseB, pulseC] - -const HUM = charge -const DIR = join(tmpdir(), "opencode-sfx") - -const LIST = [ - "ffplay", - "mpv", - "mpg123", - "mpg321", - "mplayer", - "afplay", - "play", - "omxplayer", - "aplay", - "cmdmp3", - "cvlc", - "powershell.exe", -] as const - -type Kind = (typeof LIST)[number] - -function args(kind: Kind, file: string, volume: number) { - if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file] - if (kind === "mpv") - return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file] - if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file] - if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file] - if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file] - if (kind === "play") return [kind, "-v", String(volume), file] - if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file] - return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`] -} - -let item: Player | null | undefined -let kind: Kind | null | undefined -let proc: Process.Child | undefined -let tail: ReturnType | undefined -let cache: Promise<{ hum: string; pulse: string[] }> | undefined -let seq = 0 -let shot = 0 - -function load() { - if (item !== undefined) return item - try { - item = new Player({ volume: 0.35 }) - } catch { - item = null - } - return item -} - -async function file(path: string) { - mkdirSync(DIR, { recursive: true }) - const next = join(DIR, basename(path)) - const out = Bun.file(next) - if (await out.exists()) return next - await Bun.write(out, Bun.file(path)) - return next -} - -function asset() { - cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse })) - return cache -} - -function pick() { - if (kind !== undefined) return kind - kind = LIST.find((item) => which(item)) ?? null - return kind -} - -function run(file: string, volume: number) { - const kind = pick() - if (!kind) return - return Process.spawn(args(kind, file, volume), { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }) -} - -function clear() { - if (!tail) return - clearTimeout(tail) - tail = undefined -} - -function play(file: string, volume: number) { - const item = load() - if (!item) return run(file, volume)?.exited - return item.play(file, { volume }).catch(() => run(file, volume)?.exited) -} - -export function start() { - stop() - const id = ++seq - void asset().then(({ hum }) => { - if (id !== seq) return - const next = run(hum, 0.24) - if (!next) return - proc = next - void next.exited.then( - () => { - if (id !== seq) return - if (proc === next) proc = undefined - }, - () => { - if (id !== seq) return - if (proc === next) proc = undefined - }, - ) - }) -} - -export function stop(delay = 0) { - seq++ - clear() - if (!proc) return - const next = proc - if (delay <= 0) { - proc = undefined - void Process.stop(next).catch(() => undefined) - return - } - tail = setTimeout(() => { - tail = undefined - if (proc === next) proc = undefined - void Process.stop(next).catch(() => undefined) - }, delay) -} - -export function pulse(scale = 1) { - stop(140) - const index = shot++ % FILE.length - void asset() - .then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale)) - .catch(() => undefined) -} - -export function dispose() { - stop() -} - -export * as Sound from "./sound" diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 628aa9569..6fd7b573e 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -78,8 +78,8 @@ export function FormatError(input: unknown) { ].join("\n") } - // UICancelledError: void (no data) - if (NamedError.hasName(input, "UICancelledError")) { + // UICancelledError: user cancelled an interactive CLI prompt + if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) { return "" } } diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 7b4cf7f34..6ad6495cf 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,6 +1,5 @@ -import z from "zod" import { EOL } from "os" -import { NamedError } from "@opencode-ai/core/util/error" +import { Schema } from "effect" import { logo as glyphs } from "./logo" const wordmark = [ @@ -10,7 +9,7 @@ const wordmark = [ `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`, ] -export const CancelledError = NamedError.create("UICancelledError", z.void()) +export class CancelledError extends Schema.TaggedErrorClass()("UICancelledError", {}) {} export const Style = { TEXT_HIGHLIGHT: "\x1b[96m", diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index e26c4068b..3da260ea6 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -4,9 +4,6 @@ import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" -import z from "zod" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" @@ -36,14 +33,11 @@ export const Info = Schema.Struct({ model: Schema.optional(Schema.String), source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])), // Some command templates are lazy promises from MCP prompt resolution. - template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }), + template: Schema.Unknown, subtask: Schema.optional(Schema.Boolean), hints: Schema.Array(Schema.String), -}) - .annotate({ identifier: "Command" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Command" }) -// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it export type Info = Omit, "template"> & { template: Promise | string } export function hints(template: string) { diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 94c8d8fe0..a6719e867 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -2,8 +2,7 @@ export * as ConfigAgent from "./agent" import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" -import { zod } from "@opencode-ai/core/effect-zod" -import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" @@ -102,9 +101,7 @@ export const Info = AgentSchema.pipe( decode: SchemaGetter.transform(normalize), encode: SchemaGetter.passthrough({ strict: false }), }), -) - .annotate({ identifier: "AgentConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +).annotate({ identifier: "AgentConfig" }) export type Info = Schema.Schema.Type export async function load(dir: string) { @@ -134,7 +131,7 @@ export async function load(dir: string) { ...md.data, prompt: md.content.trim(), } - result[config.name] = ConfigParse.effectSchema(Info, config, item) + result[config.name] = ConfigParse.schema(Info, config, item) } return result } diff --git a/packages/opencode/src/config/attachment.ts b/packages/opencode/src/config/attachment.ts index 7af429afd..a5fc59973 100644 --- a/packages/opencode/src/config/attachment.ts +++ b/packages/opencode/src/config/attachment.ts @@ -1,8 +1,7 @@ export * as ConfigAttachment from "./attachment" import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { PositiveInt } from "@opencode-ai/core/schema" export const Image = Schema.Struct({ auto_resize: Schema.optional(Schema.Boolean).annotate({ @@ -17,14 +16,10 @@ export const Image = Schema.Struct({ max_base64_bytes: Schema.optional(PositiveInt).annotate({ description: "Maximum base64 payload bytes for an image attachment (default: 4718592)", }), -}) - .annotate({ identifier: "ImageAttachmentConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "ImageAttachmentConfig" }) export type Image = Schema.Schema.Type export const Info = Schema.Struct({ image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }), -}) - .annotate({ identifier: "AttachmentConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "AttachmentConfig" }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index c611f3c19..fa64aef8c 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -1,12 +1,10 @@ export * as ConfigCommand from "./command" import * as Log from "@opencode-ai/core/util/log" -import { Schema } from "effect" +import { Cause, Exit, Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" @@ -20,10 +18,12 @@ export const Info = Schema.Struct({ agent: Schema.optional(Schema.String), model: Schema.optional(ConfigModelID), subtask: Schema.optional(Schema.Boolean), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type Info = Schema.Schema.Type +const decodeInfo = Schema.decodeUnknownExit(Info) + export async function load(dir: string) { const result: Record = {} for (const item of await Glob.scan("{command,commands}/**/*.md", { @@ -51,12 +51,12 @@ export async function load(dir: string) { ...md.data, template: md.content.trim(), } - const parsed = Info.zod.safeParse(config) - if (parsed.success) { - result[config.name] = parsed.data + const parsed = decodeInfo(config, { errors: "all", propertyOrder: "original" }) + if (Exit.isSuccess(parsed)) { + result[config.name] = parsed.value continue } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + throw new InvalidError({ path: item, message: Cause.pretty(parsed.cause) }, { cause: Cause.squash(parsed.cause) }) } return result } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 41ccac749..78bb83ed8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -2,7 +2,6 @@ import * as Log from "@opencode-ai/core/util/log" import path from "path" import { pathToFileURL } from "url" import os from "os" -import z from "zod" import { mergeDeep } from "remeda" import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" @@ -22,8 +21,7 @@ import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath } from "../project/instance-context" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" +import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" import { ConfigAttachment } from "./attachment" import { ConfigCommand } from "./command" @@ -57,6 +55,16 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info { if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } + // Accumulate permission layers for later merging as rulesets. + // This preserves the ordering semantics: later rules override earlier rules. + // Each layer keeps the raw shape the user wrote on disk; consumers should use + // ConfigPermission.toLayers to normalise. + if (source.permission) { + merged.permission = [ + ...ConfigPermission.toLayers(target.permission), + ...ConfigPermission.toLayers(source.permission), + ] + } return merged } @@ -112,8 +120,6 @@ async function resolveLoadedPlugins( return config } -export const Server = ConfigServer.Server.zod -export const Layout = ConfigLayout.Layout.zod export type Layout = ConfigLayout.Layout const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({ @@ -121,14 +127,6 @@ const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate description: "Log level", }) -// The Effect Schema is the canonical source of truth. The `.zod` compatibility -// surface is derived from it so plugin/SDK Zod consumers keep working without -// a parallel hand-maintained Zod definition. -// -// The walker emits `z.object({...})` which is non-strict by default. Config -// historically uses `.strict()` (additionalProperties: false in openapi.json), -// so layer that on after derivation. Re-apply the Config ref afterward -// since `.strict()` strips the walker's meta annotation. export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation", @@ -145,7 +143,7 @@ export const Info = Schema.Struct({ }), skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }), reference: Schema.optional(ConfigReference.Info).annotate({ - description: "Named git or local directory references that can be @ mentioned as Scout-backed subagents", + description: "Named git or local directory references that can be mentioned as @alias or @alias/path", }), watcher: Schema.optional( Schema.Struct({ @@ -240,7 +238,12 @@ export const Info = Schema.Struct({ description: "Additional instruction files or patterns to include", }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), - permission: Schema.optional(ConfigPermission.Info), + permission: Schema.optional( + Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]), + ).annotate({ + description: + "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.", + }), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), attachment: Schema.optional(ConfigAttachment.Info).annotate({ description: "Attachment processing configuration, including image size limits and resizing behavior", @@ -301,15 +304,7 @@ export const Info = Schema.Struct({ }), }), ), -}) - .annotate({ identifier: "Config" }) - .pipe( - withStatics((s) => ({ - zod: (zod(s) as unknown as z.ZodObject).strict().meta({ ref: "Config" }) as unknown as z.ZodType< - DeepMutable> - >, - })), - ) +}).annotate({ identifier: "Config" }) // Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition // there for why the local variant is needed over `Types.DeepMutable` from @@ -376,14 +371,11 @@ function writableGlobal(info: Info) { return next } -export const ConfigDirectoryTypoError = NamedError.create( - "ConfigDirectoryTypoError", - z.object({ - path: z.string(), - dir: z.string(), - suggestion: z.string(), - }), -) +export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoError", { + path: Schema.String, + dir: Schema.String, + suggestion: Schema.String, +}) export const layer = Layer.effect( Service, @@ -407,7 +399,7 @@ export const layer = Layer.effect( ), ) const parsed = ConfigParse.jsonc(expanded, source) - const data = ConfigParse.effectSchema(Info, normalizeLoadedConfig(parsed, source), source) + const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) if (!("path" in options)) return data yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) @@ -428,6 +420,16 @@ export const layer = Layer.effect( const loadGlobal = Effect.fnUntraced(function* () { let result: Info = {} + // Seed the default global config with the schema for editor completion, but avoid writing when the user + // explicitly routes config through env-provided paths or content. + if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) { + const file = globalConfigFile() + if (!existsSync(file)) { + yield* fs + .writeWithDirs(file, JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)) + .pipe(Effect.catch(() => Effect.void)) + } + } result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"))) result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"))) result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))) @@ -717,11 +719,12 @@ export const layer = Layer.effect( } if (Flag.OPENCODE_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) + const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info + result.permission = [...ConfigPermission.toLayers(result.permission), envPermission] } if (result.tools) { - const perms: Record = {} + const perms: ConfigPermission.Info = {} for (const [tool, enabled] of Object.entries(result.tools)) { const action: ConfigPermission.Action = enabled ? "allow" : "deny" if (tool === "write" || tool === "edit" || tool === "patch") { @@ -730,7 +733,8 @@ export const layer = Layer.effect( } perms[tool] = action } - result.permission = mergeDeep(perms, result.permission ?? {}) + // Tools permissions come before other permissions (they can be overridden) + result.permission = [perms, ...ConfigPermission.toLayers(result.permission)] } if (!result.username) result.username = os.userInfo().username @@ -805,7 +809,7 @@ export const layer = Layer.effect( let next: Info let changed: boolean if (!file.endsWith(".jsonc")) { - const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file) + const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file) const merged = mergeDeep(writable(existing), patch) const serialized = JSON.stringify(merged, null, 2) changed = serialized !== before @@ -813,7 +817,7 @@ export const layer = Layer.effect( next = merged } else { const updated = patchJsonc(before, patch) - next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file) + next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file) changed = updated !== before if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 485e33416..d52a14840 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,14 +1,11 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" import { NonNegativeInt } from "@opencode-ai/core/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), activeOrgName: Schema.optional(Schema.String), switchableOrgCount: NonNegativeInt, -}) { - static readonly zod = zod(this) -} +}) {} export const emptyConsoleState: ConsoleState = ConsoleState.make({ consoleManagedProviders: [], diff --git a/packages/opencode/src/config/error.ts b/packages/opencode/src/config/error.ts index c43598048..17d74fc1c 100644 --- a/packages/opencode/src/config/error.ts +++ b/packages/opencode/src/config/error.ts @@ -1,21 +1,23 @@ export * as ConfigError from "./error" -import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" +import { Schema } from "effect" -export const JsonError = NamedError.create( - "ConfigJsonError", - z.object({ - path: z.string(), - message: z.string().optional(), +const Issue = Schema.StructWithRest( + Schema.Struct({ + message: Schema.String, + path: Schema.Array(Schema.String), }), + [Schema.Record(Schema.String, Schema.Unknown)], ) -export const InvalidError = NamedError.create( - "ConfigInvalidError", - z.object({ - path: z.string(), - issues: z.custom().optional(), - message: z.string().optional(), - }), -) +export const JsonError = NamedError.create("ConfigJsonError", { + path: Schema.String, + message: Schema.optional(Schema.String), +}) + +export const InvalidError = NamedError.create("ConfigInvalidError", { + path: Schema.String, + issues: Schema.optional(Schema.Array(Issue)), + message: Schema.optional(Schema.String), +}) diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 222a75005..7539fe4a7 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,17 +1,13 @@ export * as ConfigFormatter from "./formatter" import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" export const Entry = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), environment: Schema.optional(Schema.Record(Schema.String, Schema.String)), extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) -export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe( - withStatics((s) => ({ zod: zod(s) })), -) +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts index a5299ea95..3ac63576d 100644 --- a/packages/opencode/src/config/layout.ts +++ b/packages/opencode/src/config/layout.ts @@ -1,10 +1,6 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" -export const Layout = Schema.Literals(["auto", "stretch"]) - .annotate({ identifier: "LayoutConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Layout = Schema.Literals(["auto", "stretch"]).annotate({ identifier: "LayoutConfig" }) export type Layout = Schema.Schema.Type export * as ConfigLayout from "./layout" diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index accfbee3b..ea7328a80 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,13 +1,11 @@ export * as ConfigLSP from "./lsp" import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import * as LSPServer from "../lsp/server" export const Disabled = Schema.Struct({ disabled: Schema.Literal(true), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}).pipe((schema) => schema) export const Entry = Schema.Union([ Disabled, @@ -18,7 +16,7 @@ export const Entry = Schema.Union([ env: Schema.optional(Schema.Record(Schema.String, Schema.String)), initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }), -]).pipe(withStatics((s) => ({ zod: zod(s) }))) +]).pipe((schema) => schema) /** * For custom (non-builtin) LSP server entries, `extensions` is required so the @@ -40,6 +38,6 @@ export const requiresExtensionsForCustomServers = Schema.makeFilter< export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]) .check(requiresExtensionsForCustomServers) - .pipe(withStatics((s) => ({ zod: zod(s) }))) + .pipe((schema) => schema) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index 390f7f8b0..820f4bf64 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -1,6 +1,6 @@ import { NamedError } from "@opencode-ai/core/util/error" import matter from "gray-matter" -import { z } from "zod" +import { Schema } from "effect" import { Filesystem } from "@/util/filesystem" export const FILE_REGEX = /(? ({ zod: zod(s) }))) +}).annotate({ identifier: "McpLocalConfig" }) export type Local = Schema.Schema.Type export const OAuth = Schema.Struct({ @@ -32,9 +29,7 @@ export const OAuth = Schema.Struct({ redirectUri: Schema.optional(Schema.String).annotate({ description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", }), -}) - .annotate({ identifier: "McpOAuthConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "McpOAuthConfig" }) export type OAuth = Schema.Schema.Type export const Remote = Schema.Struct({ @@ -52,14 +47,10 @@ export const Remote = Schema.Struct({ timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), -}) - .annotate({ identifier: "McpRemoteConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "McpRemoteConfig" }) export type Remote = Schema.Schema.Type -export const Info = Schema.Union([Local, Remote]) - .annotate({ discriminator: "type" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Info = Schema.Union([Local, Remote]).annotate({ discriminator: "type" }) export type Info = Schema.Schema.Type export * as ConfigMCP from "./mcp" diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 26fa2e0b3..ba763f999 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,14 +1,5 @@ import { Schema } from "effect" -import z from "zod" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" -// The original Zod schema carried an external $ref pointing at the models.dev -// JSON schema. That external reference is not a named SDK component — it is a -// literal pointer to an outside schema — so the walker cannot re-derive it -// from AST metadata. Preserve the exact original Zod via ZodOverride. -export const ConfigModelID = Schema.String.annotate({ - [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const ConfigModelID = Schema.String export type ConfigModelID = Schema.Schema.Type diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index f964ed4e1..90e96334f 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -2,12 +2,9 @@ export * as ConfigParse from "./parse" import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect" -import z from "zod" import type { DeepMutable } from "@opencode-ai/core/schema" import { InvalidError, JsonError } from "./error" -type ZodSchema = z.ZodType - export function jsonc(text: string, filepath: string): unknown { const errors: JsoncParseError[] = [] const data = parseJsoncImpl(text, errors, { allowTrailingComma: true }) @@ -35,17 +32,7 @@ export function jsonc(text: string, filepath: string): unknown { return data } -export function schema(schema: ZodSchema, data: unknown, source: string): T { - const parsed = schema.safeParse(data) - if (parsed.success) return parsed.data - - throw new InvalidError({ - path: source, - issues: parsed.error.issues, - }) -} - -export function effectSchema>( +export function schema>( schema: S, data: unknown, source: string, @@ -60,7 +47,7 @@ export function effectSchema>( keys: extra, path: [], message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`, - } as z.core.$ZodIssue, + }, ], }) } @@ -73,8 +60,12 @@ export function effectSchema>( { path: source, issues: EffectSchema.isSchemaError(error) - ? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[]) - : ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]), + ? SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues.map((issue) => ({ + ...issue, + message: issue.message, + path: issue.path?.map(String) ?? [], + })) + : [{ message: String(error), path: [] }], }, { cause: error }, ) diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 8c5f85499..c780d2443 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,21 +1,13 @@ export * as ConfigPermission from "./permission" import { Schema, SchemaGetter } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" -export const Action = Schema.Literals(["ask", "allow", "deny"]) - .annotate({ identifier: "PermissionActionConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" }) export type Action = Schema.Schema.Type -export const Object = Schema.Record(Schema.String, Action) - .annotate({ identifier: "PermissionObjectConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Object = Schema.Record(Schema.String, Action).annotate({ identifier: "PermissionObjectConfig" }) export type Object = Schema.Schema.Type -export const Rule = Schema.Union([Action, Object]) - .annotate({ identifier: "PermissionRuleConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Rule = Schema.Union([Action, Object]).annotate({ identifier: "PermissionRuleConfig" }) export type Rule = Schema.Schema.Type // Known permission keys get explicit types in the Effect schema for generated @@ -35,7 +27,6 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), - codesearch: Schema.optional(Action), repo_clone: Schema.optional(Rule), repo_overview: Schema.optional(Rule), lsp: Schema.optional(Rule), @@ -62,12 +53,14 @@ export const Info = InputSchema.pipe( // of the same rules. encode: SchemaGetter.passthrough({ strict: false }), }), -) - .annotate({ identifier: "PermissionConfig" }) - .pipe( - // Walker already emits the decodeTo transform into the derived zod (see - // `encoded()` in effect-zod.ts), so just expose that directly. - withStatics((s) => ({ zod: zod(s) })), - ) +).annotate({ identifier: "PermissionConfig" }) type _Info = Schema.Schema.Type export type Info = { -readonly [K in keyof _Info]: _Info[K] } + +// Top-level config accepts either a single permission object or an array of +// layered configs. Internal merging produces arrays; this helper normalises +// either shape into the array form expected by consumers. +export function toLayers(value: Info | Info[] | undefined): Info[] { + if (!value) return [] + return Array.isArray(value) ? value : [value] +} diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index b1e3ec6f4..1c4d4037e 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -2,18 +2,14 @@ import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import path from "path" -export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Options = Schema.Record(Schema.String, Schema.Unknown) export type Options = Schema.Schema.Type // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. // It answers "what should we load?" but says nothing about where that value came from. -export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe( - withStatics((s) => ({ zod: zod(s) })), -) +export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]) export type Spec = Schema.Schema.Type export type Scope = "global" | "local" diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index af9aac696..5635512ce 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { ModelStatus } from "@/provider/model-status" export const Model = Schema.Struct({ @@ -67,7 +66,7 @@ export const Model = Schema.Struct({ ), ).annotate({ description: "Variant-specific configuration" }), ), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export const Info = Schema.Struct({ api: Schema.optional(Schema.String), @@ -106,9 +105,7 @@ export const Info = Schema.Struct({ ), ), models: Schema.optional(Schema.Record(Schema.String, Model)), -}) - .annotate({ identifier: "ProviderConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "ProviderConfig" }) export type Info = Schema.Schema.Type export * as ConfigProvider from "./provider" diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index 36a8faff7..b3dec491a 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,8 +1,6 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" const Git = Schema.Struct({ repository: Schema.String.annotate({ @@ -21,7 +19,5 @@ const Local = Schema.Struct({ export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" }) -export const Info = Schema.Record(Schema.String, Entry) - .annotate({ identifier: "ReferenceConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" }) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 159ba0ce5..642adbe51 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { PositiveInt } from "@opencode-ai/core/schema" export const Server = Schema.Struct({ port: Schema.optional(PositiveInt).annotate({ @@ -14,9 +13,7 @@ export const Server = Schema.Struct({ cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional domains to allow for CORS", }), -}) - .annotate({ identifier: "ServerConfig" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "ServerConfig" }) export type Server = Schema.Schema.Type export * as ConfigServer from "./server" diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index f707e922e..38c0017d0 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,6 +1,4 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ paths: Schema.optional(Schema.Array(Schema.String)).annotate({ @@ -9,7 +7,7 @@ export const Info = Schema.Struct({ urls: Schema.optional(Schema.Array(Schema.String)).annotate({ description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", }), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 605d114ac..1c85d125a 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -22,11 +22,10 @@ export const WorktreeAdapter: WorkspaceAdapter = { description: "Create a git worktree", async configure(info) { const { AppRuntime, Worktree } = await loadWorktree() - const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo())) + const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true }))) return { ...info, name: next.name, - branch: next.branch, directory: next.directory, } }, @@ -38,7 +37,7 @@ export const WorktreeAdapter: WorkspaceAdapter = { svc.createFromInfo({ name: config.name, directory: config.directory, - branch: config.branch ?? config.name, + ...(config.branch ? { branch: config.branch } : {}), }), ), ) @@ -48,9 +47,8 @@ export const WorktreeAdapter: WorkspaceAdapter = { return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({ type: "worktree", name: info.name, - branch: info.branch ?? null, + branch: info.branch, directory: info.directory, - extra: null, projectID: Instance.project.id, })) }, diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index e78d728e0..e55ae2194 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -7,9 +7,9 @@ export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, type: Schema.String, name: Schema.String, - branch: Schema.NullOr(Schema.String), - directory: Schema.NullOr(Schema.String), - extra: Schema.NullOr(Schema.Unknown), + branch: Schema.optional(Schema.NullOr(Schema.String)), + directory: Schema.optional(Schema.NullOr(Schema.String)), + extra: Schema.optional(Schema.NullOr(Schema.Unknown)), projectID: ProjectID, }).annotate({ identifier: "Workspace" }) export type WorkspaceInfo = DeepMutable> diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index 0a2973de5..b6956032a 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -2,7 +2,9 @@ import { Context, Effect, Layer } from "effect" import { Database } from "./storage/db" import { DataMigrationTable } from "./data-migration.sql" import * as Log from "@opencode-ai/core/util/log" -import { eq } from "drizzle-orm" +import { and, asc, eq, gt, inArray, sql } from "drizzle-orm" +import { MessageTable, SessionTable } from "./session/session.sql" +import type { SessionID } from "./session/schema" export type Migration = { name: string @@ -18,7 +20,105 @@ export class Service extends Context.Service()("@opencode/Da export const layer = Layer.effect( Service, Effect.gen(function* () { - const migrations: Migration[] = [] + const migrations: Migration[] = [ + { + name: "session_usage_from_messages", + run: Effect.gen(function* () { + type Usage = { + cost: number + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + } + + for (let cursor: SessionID | undefined, page = 1; ; page++) { + const next = yield* Effect.gen(function* () { + const sessions = yield* Effect.sync(() => + Database.use((db) => + db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(cursor ? gt(SessionTable.id, cursor) : undefined) + .orderBy(asc(SessionTable.id)) + .limit(100) + .all(), + ), + ) + if (sessions.length === 0) return + + yield* Effect.sync(() => + Database.transaction((db) => { + const usageBySession = new Map( + sessions.map((session) => [ + session.id, + { cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, + ]), + ) + + for (const row of db + .select({ + session_id: MessageTable.session_id, + cost: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.cost'), 0)), 0)`, + tokens_input: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.input'), 0)), 0)`, + tokens_output: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.output'), 0)), 0)`, + tokens_reasoning: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.reasoning'), 0)), 0)`, + tokens_cache_read: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.read'), 0)), 0)`, + tokens_cache_write: sql`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.write'), 0)), 0)`, + }) + .from(MessageTable) + .where( + and( + inArray( + MessageTable.session_id, + sessions.map((session) => session.id), + ), + sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`, + ), + ) + .groupBy(MessageTable.session_id) + .all()) { + const current = usageBySession.get(row.session_id) + if (!current) continue + current.cost = row.cost + current.tokens.input = row.tokens_input + current.tokens.output = row.tokens_output + current.tokens.reasoning = row.tokens_reasoning + current.tokens.cache.read = row.tokens_cache_read + current.tokens.cache.write = row.tokens_cache_write + } + + for (const [sessionID, value] of usageBySession) { + db.update(SessionTable) + .set({ + cost: value.cost, + tokens_input: value.tokens.input, + tokens_output: value.tokens.output, + tokens_reasoning: value.tokens.reasoning, + tokens_cache_read: value.tokens.cache.read, + tokens_cache_write: value.tokens.cache.write, + time_updated: sql`${SessionTable.time_updated}`, + }) + .where(eq(SessionTable.id, sessionID)) + .run() + } + }), + ) + + return sessions.at(-1)?.id + }).pipe( + Effect.withSpan("DataMigration.sessionUsage.page", { + attributes: { + "data_migration.name": "session_usage_from_messages", + "data_migration.page": page, + "data_migration.cursor": cursor ?? "", + }, + }), + ) + if (!next) return + cursor = next + yield* Effect.sleep("10 millis") + } + }), + }, + ] yield* Effect.gen(function* () { if (migrations.length === 0) return @@ -46,7 +146,9 @@ export const layer = Layer.effect( ) } }).pipe( - Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })), + Effect.tapCause((cause) => + Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)), + ), Effect.ignore, Effect.forkScoped, ) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 4c1637006..b0efab1ae 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -55,6 +55,7 @@ import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" import { DataMigration } from "@/data-migration" +import { BackgroundJob } from "@/background/job" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -81,6 +82,7 @@ export const AppLayer = Layer.mergeAll( Todo.defaultLayer, Session.defaultLayer, SessionStatus.defaultLayer, + BackgroundJob.defaultLayer, SessionRunState.defaultLayer, SessionProcessor.defaultLayer, SessionCompaction.defaultLayer, diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index 1e7d4c296..5d7e8778d 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -181,7 +181,7 @@ export const make = ( return [ Effect.gen(function* () { yield* Fiber.interrupt(st.run.fiber) - yield* Deferred.await(st.run.done).pipe(Effect.exit, Effect.asVoid) + yield* Deferred.fail(st.run.done, new Cancelled()).pipe(Effect.asVoid) yield* idleIfCurrent() }), { _tag: "Idle" } as const, diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts new file mode 100644 index 000000000..3dfb2e99f --- /dev/null +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -0,0 +1,46 @@ +import { Config, ConfigProvider, Context, Effect, Layer } from "effect" +import { ConfigService } from "@/effect/config-service" + +const bool = (name: string) => Config.boolean(name).pipe(Config.withDefault(false)) +const experimental = bool("OPENCODE_EXPERIMENTAL") +const enabledByExperimental = (name: string) => + Config.all({ experimental, enabled: bool(name) }).pipe(Config.map((flags) => flags.experimental || flags.enabled)) + +export class Service extends ConfigService.Service()("@opencode/RuntimeFlags", { + pure: bool("OPENCODE_PURE"), + disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"), + enableExa: Config.all({ + experimental, + enabled: bool("OPENCODE_ENABLE_EXA"), + legacy: bool("OPENCODE_EXPERIMENTAL_EXA"), + }).pipe(Config.map((flags) => flags.experimental || flags.enabled || flags.legacy)), + enableParallel: Config.all({ + enabled: bool("OPENCODE_ENABLE_PARALLEL"), + legacy: bool("OPENCODE_EXPERIMENTAL_PARALLEL"), + }).pipe(Config.map((flags) => flags.enabled || flags.legacy)), + enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"), + experimentalScout: enabledByExperimental("OPENCODE_EXPERIMENTAL_SCOUT"), + experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), + experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), + client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), +}) {} + +export type Info = Context.Service.Shape + +const emptyConfigLayer = Service.defaultLayer.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), + Layer.orDie, +) + +export const layer = (overrides: Partial = {}) => + Layer.effect( + Service, + Effect.gen(function* () { + const flags = yield* Service + return Service.of({ ...flags, ...overrides }) + }), + ).pipe(Layer.provide(emptyConfigLayer)) + +export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie) + +export * as RuntimeFlags from "./runtime-flags" diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 52f2b8486..b951a4d7a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -14,17 +14,14 @@ import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema" +import { NonNegativeInt, type DeepMutable } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ path: Schema.String, added: NonNegativeInt, removed: NonNegativeInt, status: Schema.Literals(["added", "deleted", "modified"]), -}) - .annotate({ identifier: "File" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "File" }) export type Info = DeepMutable> export const Node = Schema.Struct({ @@ -33,9 +30,7 @@ export const Node = Schema.Struct({ absolute: Schema.String, type: Schema.Literals(["file", "directory"]), ignored: Schema.Boolean, -}) - .annotate({ identifier: "FileNode" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "FileNode" }) export type Node = DeepMutable> const Hunk = Schema.Struct({ @@ -62,9 +57,7 @@ export const Content = Schema.Struct({ patch: Schema.optional(Patch), encoding: Schema.optional(Schema.Literal("base64")), mimeType: Schema.optional(Schema.String), -}) - .annotate({ identifier: "FileContent" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "FileContent" }) export type Content = DeepMutable> export const Event = { diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 8459dd9ac..aae794c1a 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -11,8 +11,7 @@ import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" @@ -69,7 +68,7 @@ export const SearchMatch = Schema.Struct({ end: NonNegativeInt, }), ), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export const Match = Schema.Struct({ type: Schema.Literal("match"), diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 146d7b4d0..ecbf76424 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -4,7 +4,6 @@ import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" import { readdir } from "fs/promises" import path from "path" -import z from "zod" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index c9ab433f1..b6eb9dfd0 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -7,8 +7,6 @@ import { mergeDeep } from "remeda" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import * as Formatter from "./formatter" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "format" }) @@ -16,9 +14,7 @@ export const Status = Schema.Struct({ name: Schema.String, extensions: Schema.Array(Schema.String), enabled: Schema.Boolean, -}) - .annotate({ identifier: "FormatterStatus" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "FormatterStatus" }) export type Status = Schema.Schema.Type export interface Interface { diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 9e163cd6b..847a5c032 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -1,6 +1,7 @@ import { randomBytes } from "crypto" const prefixes = { + job: "job", event: "evt", session: "ses", message: "msg", diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index 2df293f16..a31c5bd05 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,5 +1,4 @@ import { BusEvent } from "@/bus/bus-event" -import z from "zod" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" @@ -24,14 +23,11 @@ export const Event = { ), } -export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({})) +export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {}) -export const InstallFailedError = NamedError.create( - "InstallFailedError", - z.object({ - stderr: z.string(), - }), -) +export const InstallFailedError = NamedError.create("InstallFailedError", { + stderr: Schema.String, +}) export function ide() { if (process.env["TERM_PROGRAM"] === "vscode") { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 4c8e44704..d20f29dd4 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -39,6 +39,7 @@ import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { drizzle } from "drizzle-orm/bun-sqlite" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" +import { isRecord } from "@/util/record" const processMetadata = ensureProcessMetadata("main") @@ -203,13 +204,6 @@ try { } } catch (e) { let data: Record = {} - if (e instanceof NamedError) { - const obj = e.toObject() - Object.assign(data, { - ...obj.data, - }) - } - if (e instanceof Error) { Object.assign(data, { name: e.name, @@ -219,6 +213,16 @@ try { }) } + if (e instanceof NamedError) { + const obj = e.toObject() + if (isRecord(obj.data)) { + for (const [key, value] of Object.entries(obj.data)) { + if (key === "name" || key === "stack" || key === "cause") continue + data[key] = value + } + } + } + if (e instanceof ResolveMessage) { Object.assign(data, { name: e.name, diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index be3bc4769..e8c434276 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -4,7 +4,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" -import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" @@ -45,15 +44,11 @@ export function getReleaseType(current: string, latest: string): ReleaseType { return "patch" } -export const Info = z - .object({ - version: z.string(), - latest: z.string(), - }) - .meta({ - ref: "InstallationInfo", - }) -export type Info = z.infer +export const Info = Schema.Struct({ + version: Schema.String, + latest: Schema.String, +}).annotate({ identifier: "InstallationInfo" }) +export type Info = Schema.Schema.Type export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 809ea9509..ac9706fc3 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -7,7 +7,6 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types import * as Log from "@opencode-ai/core/util/log" import { Process } from "@/util/process" import { LANGUAGE_EXTENSIONS } from "./language" -import z from "zod" import { Schema } from "effect" import type * as LSPServer from "./server" import { NamedError } from "@opencode-ai/core/util/error" @@ -32,12 +31,9 @@ export type Info = NonNullable>> export type Diagnostic = VSCodeDiagnostic -export const InitializeError = NamedError.create( - "LSPInitializeError", - z.object({ - serverID: z.string(), - }), -) +export const InitializeError = NamedError.create("LSPInitializeError", { + serverID: Schema.String, +}) export const Event = { Diagnostics: BusEvent.define( diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index a647dc099..0249721c4 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -5,7 +5,6 @@ import * as LSPClient from "./client" import path from "path" import { pathToFileURL, fileURLToPath } from "url" import * as LSPServer from "./server" -import z from "zod" import { Config } from "@/config/config" import { Flag } from "@opencode-ai/core/flag/flag" import { Process } from "@/util/process" @@ -13,8 +12,7 @@ import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" const log = Log.create({ service: "lsp" }) @@ -30,9 +28,7 @@ const Position = Schema.Struct({ export const Range = Schema.Struct({ start: Position, end: Position, -}) - .annotate({ identifier: "Range" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Range" }) export type Range = typeof Range.Type export const Symbol = Schema.Struct({ @@ -42,9 +38,7 @@ export const Symbol = Schema.Struct({ uri: Schema.String, range: Range, }), -}) - .annotate({ identifier: "Symbol" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Symbol" }) export type Symbol = typeof Symbol.Type export const DocumentSymbol = Schema.Struct({ @@ -53,21 +47,15 @@ export const DocumentSymbol = Schema.Struct({ kind: NonNegativeInt, range: Range, selectionRange: Range, -}) - .annotate({ identifier: "DocumentSymbol" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "DocumentSymbol" }) export type DocumentSymbol = typeof DocumentSymbol.Type export const Status = Schema.Struct({ id: Schema.String, name: Schema.String, root: Schema.String, - status: Schema.Literals(["connected", "error"]).annotate({ - [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]), - }), -}) - .annotate({ identifier: "LSPStatus" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) + status: Schema.Literals(["connected", "error"]), +}).annotate({ identifier: "LSPStatus" }) export type Status = typeof Status.Type enum SymbolKind { diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index b07d59870..be19be0af 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -1,33 +1,35 @@ import path from "path" -import z from "zod" import { Global } from "@opencode-ai/core/global" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Option, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" -export const Tokens = z.object({ - accessToken: z.string(), - refreshToken: z.string().optional(), - expiresAt: z.number().optional(), - scope: z.string().optional(), +export const Tokens = Schema.Struct({ + accessToken: Schema.mutableKey(Schema.String), + refreshToken: Schema.mutableKey(Schema.optional(Schema.String)), + expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), + scope: Schema.mutableKey(Schema.optional(Schema.String)), }) -export type Tokens = z.infer +export type Tokens = Schema.Schema.Type -export const ClientInfo = z.object({ - clientId: z.string(), - clientSecret: z.string().optional(), - clientIdIssuedAt: z.number().optional(), - clientSecretExpiresAt: z.number().optional(), +export const ClientInfo = Schema.Struct({ + clientId: Schema.mutableKey(Schema.String), + clientSecret: Schema.mutableKey(Schema.optional(Schema.String)), + clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)), + clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), }) -export type ClientInfo = z.infer +export type ClientInfo = Schema.Schema.Type -export const Entry = z.object({ - tokens: Tokens.optional(), - clientInfo: ClientInfo.optional(), - codeVerifier: z.string().optional(), - oauthState: z.string().optional(), - serverUrl: z.string().optional(), +export const Entry = Schema.Struct({ + tokens: Schema.mutableKey(Schema.optional(Tokens)), + clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)), + codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)), + oauthState: Schema.mutableKey(Schema.optional(Schema.String)), + serverUrl: Schema.mutableKey(Schema.optional(Schema.String)), }) -export type Entry = z.infer +export type Entry = Schema.Schema.Type + +const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry)) +type AuthData = Record const filepath = path.join(Global.Path.data, "mcp-auth.json") @@ -56,8 +58,8 @@ export const layer = Layer.effect( const all = Effect.fn("McpAuth.all")(function* () { return yield* fs.readJson(filepath).pipe( - Effect.map((data) => data as Record), - Effect.catch(() => Effect.succeed({} as Record)), + Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData), + Effect.catch(() => Effect.succeed({} as AuthData)), ) }) @@ -93,7 +95,7 @@ export const layer = Layer.effect( yield* set(mcpName, entry, serverUrl) }) - const clearField = (field: K, spanName: string) => + const clearField = (field: keyof Entry, spanName: string) => Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) { const entry = yield* get(mcpName) if (entry) { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index ed74c648a..832811b28 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ListToolsResultSchema, ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, @@ -14,7 +15,6 @@ import { Config } from "@/config/config" import { ConfigMCP } from "../config/mcp" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" -import z from "zod/v4" import { Installation } from "../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { withTimeout } from "@/util/timeout" @@ -31,19 +31,12 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod as effectZod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 -const TolerantToolSchema = ToolSchema.extend({ - outputSchema: z.unknown().optional(), -}) - -const TolerantListToolsResultSchema = z.looseObject({ - tools: z.array(TolerantToolSchema), - nextCursor: z.string().optional(), +const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ + tools: ToolSchema.omit({ outputSchema: true }).array(), }) export const Resource = Schema.Struct({ @@ -52,9 +45,7 @@ export const Resource = Schema.Struct({ description: Schema.optional(Schema.String), mimeType: Schema.optional(Schema.String), client: Schema.String, -}) - .annotate({ identifier: "McpResource" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "McpResource" }) export type Resource = Schema.Schema.Type export const ToolsChanged = BusEvent.define( @@ -72,12 +63,9 @@ export const BrowserOpenFailed = BusEvent.define( }), ) -export const Failed = NamedError.create( - "MCPFailed", - z.object({ - name: z.string(), - }), -) +export const Failed = NamedError.create("MCPFailed", { + name: Schema.String, +}) type MCPClient = Client @@ -104,9 +92,7 @@ export const Status = Schema.Union([ StatusFailed, StatusNeedsAuth, StatusNeedsClientRegistration, -]) - .annotate({ identifier: "MCPStatus", discriminator: "status" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +]).annotate({ identifier: "MCPStatus", discriminator: "status" }) export type Status = Schema.Schema.Type // Store transports for OAuth servers to allow finishing auth @@ -146,7 +132,10 @@ function listTools(key: string, client: MCPClient, timeout: number) { log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error }) return Effect.tryPromise({ - try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }), + try: () => + client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { + timeout, + }), catch: (err) => (err instanceof Error ? err : new Error(String(err))), }).pipe( Effect.map((result) => diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index fd5fff562..3dfa6f2d0 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import * as path from "path" import * as fs from "fs/promises" import { readFileSync } from "fs" @@ -7,12 +7,11 @@ import * as Bom from "../util/bom" const log = Log.create({ service: "patch" }) -// Schema definitions -export const PatchSchema = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), +export const PatchSchema = Schema.Struct({ + patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), }) -export type PatchParams = z.infer +export type PatchParams = Schema.Schema.Type // Core types matching the Rust implementation export interface ApplyPatchArgs { diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index f4bd2e2cc..2f0813aff 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,9 +7,7 @@ import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@opencode-ai/core/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" @@ -18,23 +16,17 @@ import { PermissionID } from "./schema" const log = Log.create({ service: "permission" }) -export const Action = Schema.Literals(["allow", "deny", "ask"]) - .annotate({ identifier: "PermissionAction" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) export type Action = Schema.Schema.Type export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action, -}) - .annotate({ identifier: "PermissionRule" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "PermissionRule" }) export type Rule = Schema.Schema.Type -export const Ruleset = Schema.mutable(Schema.Array(Rule)) - .annotate({ identifier: "PermissionRuleset" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionRuleset" }) export type Ruleset = Schema.Schema.Type export class Request extends Schema.Class("PermissionRequest")({ @@ -50,11 +42,9 @@ export class Request extends Schema.Class("PermissionRequest")({ callID: Schema.String, }), ), -}) { - static readonly zod = zod(this) -} +}) {} -export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Reply = Schema.Literals(["once", "always", "reject"]) export type Reply = Schema.Schema.Type const reply = { @@ -62,17 +52,13 @@ const reply = { message: Schema.optional(Schema.String), } -export const ReplyBody = Schema.Struct(reply) - .annotate({ identifier: "PermissionReplyBody" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "PermissionReplyBody" }) export type ReplyBody = Schema.Schema.Type export class Approval extends Schema.Class("PermissionApproval")({ projectID: ProjectID, patterns: Schema.Array(Schema.String), -}) { - static readonly zod = zod(this) -} +}) {} export const Event = { Asked: BusEvent.define("permission.asked", Request), @@ -114,17 +100,13 @@ export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(PermissionID), ruleset: Ruleset, -}) - .annotate({ identifier: "PermissionAskInput" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "PermissionAskInput" }) export type AskInput = Schema.Schema.Type export const ReplyInput = Schema.Struct({ requestID: PermissionID, ...reply, -}) - .annotate({ identifier: "PermissionReplyInput" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "PermissionReplyInput" }) export type ReplyInput = Schema.Schema.Type export interface Interface { diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index f7c6e2c5b..58ef0a8a7 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod } from "@opencode-ai/core/effect-zod" import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( @@ -11,6 +10,4 @@ export class PermissionID extends Newtype()( static ascending(id?: string): PermissionID { return this.make(Identifier.ascending("permission", id)) } - - static readonly zod = zod(this) } diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts new file mode 100644 index 000000000..fa4adf633 --- /dev/null +++ b/packages/opencode/src/plugin/digitalocean.ts @@ -0,0 +1,411 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import type { Model } from "@opencode-ai/sdk/v2" +import * as Log from "@opencode-ai/core/util/log" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { createServer } from "http" + +const log = Log.create({ service: "plugin.digitalocean" }) + +const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82" +const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize" +const DO_API_BASE = "https://api.digitalocean.com" +const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1" +const OAUTH_PORT = 1456 +const OAUTH_REDIRECT_PATH = "/auth/callback" +const OAUTH_TOKEN_PATH = "/auth/token" +const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000 +const MAK_NAME_PREFIX = "opencode-oauth" + +interface ImplicitTokenPayload { + access_token: string + expires_in: number + state: string +} + +interface PendingOAuth { + state: string + resolve: (tokens: ImplicitTokenPayload) => void + reject: (error: Error) => void +} + +interface ApiKeyInfo { + uuid: string + name: string + secret_key: string +} + +interface RouterEntry { + name: string + uuid?: string + description?: string +} + +let oauthServer: ReturnType | undefined +let pendingOAuth: PendingOAuth | undefined + +function generateState(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +function redirectUri(): string { + return `http://localhost:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}` +} + +function buildAuthorizeUrl(state: string): string { + const params = new URLSearchParams({ + response_type: "token", + client_id: DO_OAUTH_CLIENT_ID, + redirect_uri: redirectUri(), + scope: "read write", + state, + }) + return `${DO_AUTHORIZE_URL}?${params.toString()}` +} + +const HTML_CALLBACK = ` + + + + OpenCode - DigitalOcean Authorization + + + +
+

Finishing sign-in...

+

You can close this window once it says you're signed in.

+
+ + +` + +async function startOAuthServer(): Promise { + if (oauthServer) return + oauthServer = createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`) + + if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) { + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_CALLBACK) + return + } + + if (req.method === "POST" && url.pathname === OAUTH_TOKEN_PATH) { + const chunks: Buffer[] = [] + req.on("data", (chunk: Buffer) => chunks.push(chunk)) + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8") + let body: Record = {} + try { + body = raw ? JSON.parse(raw) : {} + } catch { + body = {} + } + if (!pendingOAuth) { + res.writeHead(409, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "no_pending_oauth" })) + return + } + if (body.error) { + const message = body.error_description || body.error || "OAuth error" + pendingOAuth.reject(new Error(String(message))) + pendingOAuth = undefined + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ ok: true })) + return + } + if (!body.access_token) { + pendingOAuth.reject(new Error("Missing access_token in callback")) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "missing_access_token" })) + return + } + if (body.state !== pendingOAuth.state) { + pendingOAuth.reject(new Error("Invalid state - potential CSRF attack")) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "invalid_state" })) + return + } + const expires = parseInt(body.expires_in || "0", 10) + pendingOAuth.resolve({ + access_token: body.access_token, + expires_in: Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30, + state: body.state, + }) + pendingOAuth = undefined + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ ok: true })) + }) + return + } + + res.writeHead(404) + res.end("Not found") + }) + + await new Promise((resolve, reject) => { + oauthServer!.listen(OAUTH_PORT, () => { + log.info("digitalocean oauth server started", { port: OAUTH_PORT }) + resolve() + }) + oauthServer!.on("error", reject) + }) +} + +function stopOAuthServer() { + if (!oauthServer) return + oauthServer.close(() => log.info("digitalocean oauth server stopped")) + oauthServer = undefined +} + +function waitForOAuthCallback(state: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + if (pendingOAuth) { + pendingOAuth = undefined + reject(new Error("OAuth callback timeout - authorization took too long")) + } + }, + 5 * 60 * 1000, + ) + pendingOAuth = { + state, + resolve: (tokens) => { + clearTimeout(timeout) + resolve(tokens) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + }, + } + }) +} + +async function createModelAccessKey(bearer: string): Promise { + // Suffix-on-collision strategy keeps re-`/connect` non-destructive. + const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}` + const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, { + method: "POST", + headers: { + Authorization: `Bearer ${bearer}`, + "Content-Type": "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + }, + body: JSON.stringify({ name }), + }) + if (!res.ok) { + const body = await res.text().catch(() => "") + throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`) + } + const data = (await res.json()) as { api_key_info?: ApiKeyInfo } + if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key") + return data.api_key_info +} + +async function listRouters( + bearer: string, +): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> { + const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, { + headers: { + Authorization: `Bearer ${bearer}`, + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + }, + signal: AbortSignal.timeout(10_000), + }).catch(() => undefined) + if (!res) return { ok: false, status: 0 } + if (!res.ok) return { ok: false, status: res.status } + const body = (await res.json().catch(() => undefined)) as { model_routers?: RouterEntry[] } | undefined + return { ok: true, routers: body?.model_routers ?? [] } +} + +function routerModel(router: RouterEntry, providerID: string): Model { + const id = `router:${router.name}` + return { + id, + providerID, + name: router.name, + family: "digitalocean-inference-routers", + api: { id, url: DO_INFERENCE_BASE, npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128_000, output: 8_192 }, + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } +} + +function parseRoutersJSON(raw: string | undefined): RouterEntry[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.flatMap((r) => + r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [], + ) + } catch { + return [] + } +} + +export async function DigitalOceanAuthPlugin(input: PluginInput): Promise { + return { + provider: { + id: "digitalocean", + async models(provider, ctx) { + const baseModels = provider.models + if (ctx.auth?.type !== "api") return baseModels + + const metadata = ctx.auth.metadata ?? {} + const oauthAccess = metadata["oauth_access"] + const oauthExpires = parseInt(metadata["oauth_expires"] || "0", 10) + const fetchedAt = parseInt(metadata["routers_fetched_at"] || "0", 10) + const cached = parseRoutersJSON(metadata["routers"]) + + let routers = cached + const stale = Date.now() - fetchedAt > ROUTER_REFRESH_INTERVAL_MS + const bearerValid = oauthAccess && oauthExpires > Date.now() + + if (bearerValid && stale) { + const result = await listRouters(oauthAccess) + if (result.ok) { + routers = result.routers + const updated: Record = { + ...metadata, + routers: JSON.stringify(routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description }))), + routers_fetched_at: String(Date.now()), + } + await input.client.auth + .set({ + path: { id: "digitalocean" }, + body: { type: "api", key: ctx.auth.key, metadata: updated }, + }) + .catch((err) => log.warn("failed to persist refreshed routers", { error: err })) + } else if (result.status === 401 || result.status === 403) { + log.warn("digitalocean oauth bearer rejected; using cached routers", { status: result.status }) + } else if (result.status !== 0) { + log.warn("digitalocean router refresh failed", { status: result.status }) + } + } + + const merged: Record = { ...baseModels } + for (const router of routers) { + const id = `router:${router.name}` + if (merged[id]) continue + merged[id] = routerModel(router, "digitalocean") + } + return merged + }, + }, + auth: { + provider: "digitalocean", + methods: [ + { + type: "oauth", + label: "Login with DigitalOcean", + async authorize() { + await startOAuthServer() + const state = generateState() + const callbackPromise = waitForOAuthCallback(state) + return { + url: buildAuthorizeUrl(state), + instructions: + "Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.", + method: "auto" as const, + async callback() { + try { + const tokens = await callbackPromise + const apiKeyInfo = await createModelAccessKey(tokens.access_token) + const routerResult = await listRouters(tokens.access_token) + const routers = routerResult.ok ? routerResult.routers : [] + if (!routerResult.ok) { + log.warn("digitalocean initial router fetch failed", { status: routerResult.status }) + } + return { + type: "success" as const, + provider: "digitalocean", + key: apiKeyInfo.secret_key, + metadata: { + mak_uuid: apiKeyInfo.uuid, + mak_name: apiKeyInfo.name, + oauth_access: tokens.access_token, + oauth_expires: String(Date.now() + tokens.expires_in * 1000), + routers: JSON.stringify( + routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })), + ), + routers_fetched_at: String(Date.now()), + }, + } + } catch (err) { + log.error("digitalocean oauth callback failed", { error: err }) + return { type: "failed" as const } + } finally { + stopOAuthServer() + } + }, + } + }, + }, + { + type: "api", + label: "Paste Model Access Key", + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 8fa8dee76..a488be4a4 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -1,50 +1,51 @@ -import { z } from "zod" import type { Model } from "@opencode-ai/sdk/v2" +import { Schema } from "effect" -export const schema = z.object({ - data: z.array( - z.object({ - model_picker_enabled: z.boolean(), - id: z.string(), - name: z.string(), +export const schema = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + model_picker_enabled: Schema.Boolean, + id: Schema.String, + name: Schema.String, // every version looks like: `{model.id}-YYYY-MM-DD` - version: z.string(), - supported_endpoints: z.array(z.string()).optional(), - policy: z - .object({ - state: z.string().optional(), - }) - .optional(), - capabilities: z.object({ - family: z.string(), - limits: z.object({ - max_context_window_tokens: z.number(), - max_output_tokens: z.number(), - max_prompt_tokens: z.number(), - vision: z - .object({ - max_prompt_image_size: z.number(), - max_prompt_images: z.number(), - supported_media_types: z.array(z.string()), - }) - .optional(), + version: Schema.String, + supported_endpoints: Schema.optional(Schema.Array(Schema.String)), + policy: Schema.optional( + Schema.Struct({ + state: Schema.optional(Schema.String), }), - supports: z.object({ - adaptive_thinking: z.boolean().optional(), - max_thinking_budget: z.number().optional(), - min_thinking_budget: z.number().optional(), - reasoning_effort: z.array(z.string()).optional(), - streaming: z.boolean(), - structured_outputs: z.boolean().optional(), - tool_calls: z.boolean(), - vision: z.boolean().optional(), + ), + capabilities: Schema.Struct({ + family: Schema.String, + limits: Schema.Struct({ + max_context_window_tokens: Schema.Number, + max_output_tokens: Schema.Number, + max_prompt_tokens: Schema.Number, + vision: Schema.optional( + Schema.Struct({ + max_prompt_image_size: Schema.Number, + max_prompt_images: Schema.Number, + supported_media_types: Schema.Array(Schema.String), + }), + ), + }), + supports: Schema.Struct({ + adaptive_thinking: Schema.optional(Schema.Boolean), + max_thinking_budget: Schema.optional(Schema.Number), + min_thinking_budget: Schema.optional(Schema.Number), + reasoning_effort: Schema.optional(Schema.Array(Schema.String)), + streaming: Schema.Boolean, + structured_outputs: Schema.optional(Schema.Boolean), + tool_calls: Schema.Boolean, + vision: Schema.optional(Schema.Boolean), }), }), }), ), }) -type Item = z.infer["data"][number] +type Item = Schema.Schema.Type["data"][number] +const decodeModels = Schema.decodeUnknownSync(schema) function build(key: string, remote: Item, url: string, prev?: Model): Model { const reasoning = @@ -165,7 +166,7 @@ export async function get( if (!res.ok) { throw new Error(`Failed to fetch models: ${res.status}`) } - return schema.parse(await res.json()) + return decodeModels(await res.json()) }) const result = { ...existing } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 7a7f260df..e87f6db23 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -9,7 +9,6 @@ import { Config } from "@/config/config" import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" -import { Flag } from "@opencode-ai/core/flag/flag" import { ServerAuth } from "@/server/auth" import { CodexAuthPlugin } from "./codex" import { Session } from "@/session/session" @@ -19,6 +18,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { AzureAuthPlugin } from "./azure" +import { DigitalOceanAuthPlugin } from "./digitalocean" import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" @@ -27,6 +27,7 @@ import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { registerAdapter } from "@/control-plane/adapters" import type { WorkspaceAdapter } from "@/control-plane/types" +import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "plugin" }) @@ -64,6 +65,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [ CloudflareWorkersAuthPlugin, CloudflareAIGatewayAuthPlugin, AzureAuthPlugin, + DigitalOceanAuthPlugin, ] function isServerPlugin(value: unknown): value is PluginInstance { @@ -110,6 +112,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const bus = yield* Bus.Service const config = yield* Config.Service + const flags = yield* RuntimeFlags.Service const state = yield* InstanceState.make( Effect.fn("Plugin.state")(function* (ctx) { @@ -146,7 +149,7 @@ export const layer = Layer.effect( $: typeof Bun === "undefined" ? undefined : Bun.$, } - for (const plugin of INTERNAL_PLUGINS) { + for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) { log.info("loading internal plugin", { name: plugin.name }) const init = yield* Effect.tryPromise({ try: () => plugin(input), @@ -157,8 +160,8 @@ export const layer = Layer.effect( if (init._tag === "Some") hooks.push(init.value) } - const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? []) - if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) { + const plugins = flags.pure ? [] : (cfg.plugin_origins ?? []) + if (flags.pure && cfg.plugin_origins?.length) { log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) } if (plugins.length) yield* config.waitForDependencies() @@ -283,6 +286,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), +) export * as Plugin from "." diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 6103a9efb..a7e67d45e 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -37,7 +37,7 @@ export const layer = Layer.effect( const run = Effect.gen(function* () { const ctx = yield* InstanceState.context - yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory)) // everything depends on config so eager load it for nice traces yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 9707305f9..faa56668a 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -156,7 +156,9 @@ export const layer: Layer.Layer ({ zod: zod(s) }))) +}).annotate({ identifier: "Project" }) export type Info = Types.DeepMutable> export const Event = { @@ -89,21 +85,19 @@ export function fromRow(row: Row): Info { } } -export const UpdateInput = z.object({ - projectID: ProjectID.zod, - name: z.string().optional(), - icon: zod(ProjectIcon).optional(), - commands: zod(ProjectCommands).optional(), +export const UpdateInput = Schema.Struct({ + projectID: ProjectID, + name: Schema.optional(Schema.String), + icon: Schema.optional(ProjectIcon), + commands: Schema.optional(ProjectCommands), }) -export type UpdateInput = z.infer +export type UpdateInput = Types.DeepMutable> export const UpdatePayload = Schema.Struct({ name: Schema.optional(Schema.String), icon: Schema.optional(ProjectIcon), commands: Schema.optional(ProjectCommands), -}) - .annotate({ identifier: "ProjectUpdateInput" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "ProjectUpdateInput" }) export type UpdatePayload = Types.DeepMutable> // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index c6cff94fd..e511a75ff 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) @@ -10,6 +9,5 @@ export type ProjectID = typeof projectIdSchema.Type export const ProjectID = projectIdSchema.pipe( withStatics((schema: typeof projectIdSchema) => ({ global: schema.make("global"), - zod: zod(schema), })), ) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 092444c44..5a477e02b 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,8 +6,6 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod, zodObject } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 @@ -208,7 +206,7 @@ const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: return yield* diffAgainstRef(git, cwd, ref) }) -export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Mode = Schema.Literals(["git", "branch"]) export type Mode = Schema.Schema.Type export const Event = { @@ -223,9 +221,7 @@ export const Event = { export const Info = Schema.Struct({ branch: Schema.optional(Schema.String), default_branch: Schema.optional(Schema.String), -}) - .annotate({ identifier: "VcsInfo" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "VcsInfo" }) export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ @@ -237,9 +233,7 @@ export const FileDiff = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), -}) - .annotate({ identifier: "VcsFileDiff" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "VcsFileDiff" }) export type FileDiff = Schema.Schema.Type export const FileStatus = Schema.Struct({ @@ -247,19 +241,17 @@ export const FileStatus = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, status: Schema.Literals(["added", "deleted", "modified"]), -}) - .annotate({ identifier: "VcsFileStatus" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "VcsFileStatus" }) export type FileStatus = Schema.Schema.Type export const ApplyInput = Schema.Struct({ patch: Schema.String, -}).pipe(withStatics((s) => ({ zod: zod(s), zodObject: zodObject(s) }))) +}) export type ApplyInput = Schema.Schema.Type export const ApplyResult = Schema.Struct({ applied: Schema.Boolean, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type ApplyResult = Schema.Schema.Type export class PatchApplyError extends Schema.TaggedErrorClass()("VcsPatchApplyError", { diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 135df6fec..b63e1eaf4 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,9 +1,8 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { zod } from "@opencode-ai/core/effect-zod" -import { namedSchemaError } from "@/util/named-schema-error" -import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" +import { NamedError } from "@opencode-ai/core/util/error" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" @@ -42,40 +41,36 @@ export class Method extends Schema.Class("ProviderAuthMethod")({ type: Schema.Literals(["oauth", "api"]), label: Schema.String, prompts: optionalOmitUndefined(Schema.Array(Prompt)), -}) { - static readonly zod = zod(this) -} +}) {} -export const Methods = Schema.Record(Schema.String, Schema.Array(Method)).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const Methods = Schema.Record(Schema.String, Schema.Array(Method)) export type Methods = typeof Methods.Type export class Authorization extends Schema.Class("ProviderAuthAuthorization")({ url: Schema.String, method: Schema.Literals(["auto", "code"]), instructions: Schema.String, -}) { - static readonly zod = zod(this) -} +}) {} export const AuthorizeInput = Schema.Struct({ method: Schema.Finite.annotate({ description: "Auth method index" }), inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type AuthorizeInput = Schema.Schema.Type export const CallbackInput = Schema.Struct({ method: Schema.Finite.annotate({ description: "Auth method index" }), code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type CallbackInput = Schema.Schema.Type -export const OauthMissing = namedSchemaError("ProviderAuthOauthMissing", { providerID: ProviderID }) +export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", { providerID: ProviderID }) -export const OauthCodeMissing = namedSchemaError("ProviderAuthOauthCodeMissing", { providerID: ProviderID }) +export const OauthCodeMissing = NamedError.create("ProviderAuthOauthCodeMissing", { providerID: ProviderID }) -export const OauthCallbackFailed = namedSchemaError("ProviderAuthOauthCallbackFailed", {}) +export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", {}) -export const ValidationFailed = namedSchemaError("ProviderAuthValidationFailed", { +export const ValidationFailed = NamedError.create("ProviderAuthValidationFailed", { field: Schema.String, message: Schema.String, }) @@ -202,6 +197,7 @@ export const layer: Layer.Layer = yield* auth.set(input.providerID, { type: "api", key: result.key, + ...(result.metadata ? { metadata: result.metadata } : {}), }) } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index fb240e4cf..9f88f9db1 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -10,11 +10,23 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" import { CatalogModelStatus } from "./model-status" +const CostTier = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + cache_read: Schema.optional(Schema.Finite), + cache_write: Schema.optional(Schema.Finite), + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Finite, + }), +}) + const Cost = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache_read: Schema.optional(Schema.Finite), cache_write: Schema.optional(Schema.Finite), + tiers: Schema.optional(Schema.Array(CostTier)), context_over_200k: Schema.optional( Schema.Struct({ input: Schema.Finite, @@ -177,7 +189,9 @@ export const layer: Layer.Layer Effect.logError("Failed to fetch models.dev", { cause })), + Effect.tapCause((cause) => + Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)), + ), Effect.ignore, ) }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c27b69b6a..d4d28088d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,8 +13,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { zod } from "@opencode-ai/core/effect-zod" -import { namedSchemaError } from "@/util/named-schema-error" +import { NamedError } from "@opencode-ai/core/util/error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" import path from "path" @@ -24,7 +23,7 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" @@ -870,10 +869,21 @@ const ProviderCacheCost = Schema.Struct({ write: Schema.Finite, }) +const ProviderCostTier = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + cache: ProviderCacheCost, + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Finite, + }), +}) + const ProviderCost = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache: ProviderCacheCost, + tiers: optionalOmitUndefined(Schema.Array(ProviderCostTier)), experimentalOver200K: optionalOmitUndefined( Schema.Struct({ input: Schema.Finite, @@ -903,9 +913,7 @@ export const Model = Schema.Struct({ headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))), -}) - .annotate({ identifier: "Model" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Model" }) export type Model = Types.DeepMutable> export const Info = Schema.Struct({ @@ -916,9 +924,7 @@ export const Info = Schema.Struct({ key: optionalOmitUndefined(Schema.String), options: Schema.Record(Schema.String, Schema.Any), models: Schema.Record(Schema.String, Model), -}) - .annotate({ identifier: "Provider" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Provider" }) export type Info = Types.DeepMutable> const DefaultModelIDs = Schema.Record(Schema.String, Schema.String) @@ -927,13 +933,13 @@ export const ListResult = Schema.Struct({ all: Schema.Array(Info), default: DefaultModelIDs, connected: Schema.Array(Schema.String), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type ListResult = Types.DeepMutable> export const ConfigProvidersResult = Schema.Struct({ providers: Schema.Array(Info), default: DefaultModelIDs, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type ConfigProvidersResult = Types.DeepMutable> export function toPublicInfo(provider: Info): Info { @@ -982,6 +988,17 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { write: c?.cache_write ?? 0, }, } + if (c?.tiers) { + result.tiers = c.tiers.map((item) => ({ + input: item.input, + output: item.output, + cache: { + read: item.cache_read ?? 0, + write: item.cache_write ?? 0, + }, + tier: item.tier, + })) + } if (c?.context_over_200k) { result.experimentalOver200K = { cache: { @@ -1754,13 +1771,13 @@ export function parseModel(model: string) { } } -export const ModelNotFoundError = namedSchemaError("ProviderModelNotFoundError", { +export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", { providerID: ProviderID, modelID: ModelID, suggestions: Schema.optional(Schema.Array(Schema.String)), }) -export const InitError = namedSchemaError("ProviderInitError", { +export const InitError = NamedError.create("ProviderInitError", { providerID: ProviderID, }) diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index 757b70f3f..db05b4784 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,6 +1,5 @@ import { Schema } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) @@ -9,7 +8,6 @@ export type ProviderID = typeof providerIdSchema.Type export const ProviderID = providerIdSchema.pipe( withStatics((schema: typeof providerIdSchema) => ({ - zod: zod(schema), // Well-known providers opencode: schema.make("opencode"), anthropic: schema.make("anthropic"), @@ -29,8 +27,4 @@ const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID")) export type ModelID = typeof modelIdSchema.Type -export const ModelID = modelIdSchema.pipe( - withStatics((schema: typeof modelIdSchema) => ({ - zod: zod(schema), - })), -) +export const ModelID = modelIdSchema diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index bd778dacc..72ec881e7 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,7 +1,6 @@ import type { ModelMessage, ToolResultPart } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" -import type { JSONSchema } from "zod/v4/core" import type * as Provider from "./provider" import type * as ModelsDev from "./models" import { iife } from "@/util/iife" @@ -1281,7 +1280,7 @@ export function maxOutputTokens(model: Provider.Model): number { return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX } -export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 { +export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 { /* if (["openai", "azure"].includes(providerID)) { if (schema.type === "object" && schema.properties) { @@ -1312,7 +1311,10 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS return result } - schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7 + const sanitized = sanitizeMoonshot(schema) + if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) { + schema = sanitized + } } // Convert integer enums to string enums for Google/Gemini @@ -1394,7 +1396,7 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS schema = sanitizeGemini(schema) } - return schema as JSONSchema7 + return schema } export * as ProviderTransform from "./transform" diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 85e0840cb..6f18856fd 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -10,8 +10,7 @@ import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { NonNegativeInt, PositiveInt } from "@opencode-ai/core/schema" const log = Log.create({ service: "pty" }) @@ -62,9 +61,7 @@ export const Info = Schema.Struct({ cwd: Schema.String, status: Schema.Literals(["running", "exited"]), pid: PositiveInt, -}) - .annotate({ identifier: "Pty" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Pty" }) export type Info = Types.DeepMutable> @@ -74,7 +71,7 @@ export const CreateInput = Schema.Struct({ cwd: Schema.optional(Schema.String), title: Schema.optional(Schema.String), env: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type CreateInput = Types.DeepMutable> @@ -86,7 +83,7 @@ export const UpdateInput = Schema.Struct({ cols: PositiveInt, }), ), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type UpdateInput = Types.DeepMutable> diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index fadb0457e..c86ae8c73 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) @@ -11,6 +10,5 @@ export type PtyID = typeof ptyIdSchema.Type export const PtyID = ptyIdSchema.pipe( withStatics((schema: typeof ptyIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)), - zod: zod(schema), })), ) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index c041462ad..94182f1a2 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -3,9 +3,7 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" -import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@opencode-ai/core/schema" import { QuestionID } from "./schema" const log = Log.create({ service: "question" }) @@ -19,9 +17,7 @@ export class Option extends Schema.Class