Merge remote-tracking branch 'origin/dev' into fix/v2-session-wake-retry-dev

This commit is contained in:
Kit Langton 2026-06-06 22:01:43 -04:00
commit 6ec6593e9e
92 changed files with 4008 additions and 2574 deletions

View file

@ -1,5 +0,0 @@
---
"@opencode-ai/http-recorder": minor
---
Publish the initial beta of the Effect HTTP and WebSocket record/replay library.

View file

@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "dev",
"updateInternalDependencies": "patch",
"ignore": []
}

View file

@ -1,53 +0,0 @@
name: http-recorder release
on:
push:
branches:
- dev
paths:
- ".changeset/**"
- "packages/http-recorder/**"
- ".github/workflows/http-recorder-release.yml"
concurrency: http-recorder-release
permissions:
contents: write
id-token: write
pull-requests: write
jobs:
release:
if: github.repository == 'anomalyco/opencode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with:
fetch-depth: 0
- uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Verify package
run: |
bun run --cwd packages/http-recorder build
bun run --cwd packages/http-recorder test
bun run --cwd packages/http-recorder typecheck
bun run --cwd packages/http-recorder verify:package
- name: Version or publish beta
uses: changesets/action@3841a0683d3cfa6dae0f9bb335290003010fe3f0 # v1.9.0
with:
version: bun run version:http-recorder
publish: bun run release:http-recorder
commit: "chore(http-recorder): release beta"
title: "chore(http-recorder): release beta"
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
NPM_CONFIG_PROVENANCE: true

202
bun.lock
View file

@ -14,7 +14,6 @@
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@changesets/cli": "2.31.0",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@ -265,6 +264,7 @@
"@effect/opentelemetry": "catalog:", "@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:", "@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.9.3",
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1", "@npmcli/config": "10.8.1",
@ -1254,40 +1254,6 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="], "@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="],
"@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="],
"@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="],
"@changesets/cli": ["@changesets/cli@2.31.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.1", "@changesets/assemble-release-plan": "^6.0.10", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.4", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/get-release-plan": "^4.0.16", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg=="],
"@changesets/config": ["@changesets/config@3.1.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q=="],
"@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="],
"@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.4", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg=="],
"@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.16", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.10", "@changesets/config": "^3.1.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g=="],
"@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="],
"@changesets/git": ["@changesets/git@3.0.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@manypkg/get-packages": "^1.1.3", "is-subdir": "^1.1.1", "micromatch": "^4.0.8", "spawndamnit": "^3.0.1" } }, "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw=="],
"@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "^1.1.0" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="],
"@changesets/parse": ["@changesets/parse@0.4.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^4.1.1" } }, "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A=="],
"@changesets/pre": ["@changesets/pre@2.0.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1" } }, "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug=="],
"@changesets/read": ["@changesets/read@0.6.7", "", { "dependencies": { "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/parse": "^0.4.3", "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "p-filter": "^2.1.0", "picocolors": "^1.1.0" } }, "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA=="],
"@changesets/should-skip-package": ["@changesets/should-skip-package@0.1.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw=="],
"@changesets/types": ["@changesets/types@6.1.0", "", {}, "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA=="],
"@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="], "@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
"@clack/prompts": ["@clack/prompts@1.0.0-alpha.1", "", { "dependencies": { "@clack/core": "1.0.0-alpha.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-07MNT0OsxjKOcyVfX8KhXBhJiyUbDP1vuIAcHc+nx5v93MJO23pX3X/k3bWz6T3rpM9dgWPq90i4Jq7gZAyMbw=="], "@clack/prompts": ["@clack/prompts@1.0.0-alpha.1", "", { "dependencies": { "@clack/core": "1.0.0-alpha.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-07MNT0OsxjKOcyVfX8KhXBhJiyUbDP1vuIAcHc+nx5v93MJO23pX3X/k3bWz6T3rpM9dgWPq90i4Jq7gZAyMbw=="],
@ -1446,6 +1412,24 @@
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="], "@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-isGuuEbAo7D6psAllm4+TRONxmDfhlmm548IjsG5hEH4I/pwTTTtrRg4lpMDwQ/cD5I3kEL2KVEYdlwuyFod8w=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vJMyCHtE5/CqCmvH7kEDSkUK9/YImoGZuIrRd6yLBjpSTtwyr0QIYjXDsFSj8a4eyxP3ieZWBw9z+uekPZ4YHw=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bapVTzIJZ40WmGYpAN+X3hIOqeynNTH1WPTp6S2pDMj6WQIG0lO4zWboNRAhVxIdsBq7vJwiBm4BKN+8Wp4wzg=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-opdtJbCmDB/SjHx+IaM6DF6UpYUZ8saXbwiAHamqg8ywhBWQoGzTo66BBwbaf6kd7sv7hJsYrUVBqLJhZGfL4A=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-74kucsnuCsp0daZQGtg0YYJL8h8ypt/efzSQjEuja2GPLdZrW9zVO1p+EWP9FZIt0bAf1o71W3PWjSEa3dTLUQ=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zgbzi24qWaE1l8bFweApM8Zd1ymxfP5tf9yX9k+PqmOGdGQhGWwbWTxB6UCUu+BiLPd+78Lxzp4oIBoSsZzejA=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2ZB3LgEXWY0BJVpN6zr2JeuGYQbOZhNVZYYkKGY9g48L/nUuuB2X1HzQTLQ0zPipmFoPG7dUFlTjl+qmQhJPRw=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-K6PycT3FluRUEtOqsySbq8oHxP8XeyvdWtnxMlnaSSLc5LKlWg3CKvc+kxfq7UkpySA9LlPk+Qp/C1IvJ890QA=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.3", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.3", "@ff-labs/fff-bin-darwin-x64": "0.9.3", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.3", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-x64-musl": "0.9.3", "@ff-labs/fff-bin-win32-arm64": "0.9.3", "@ff-labs/fff-bin-win32-x64": "0.9.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-PPSsmSf1+xD/8eLelBDYFcmlmQUPRCm+GO4K/PgtuLtLu0CWsoxyStykgjw+0GP3bTUVNdHK1FYwESk0hmY6lg=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@ -1520,8 +1504,6 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="],
"@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="], "@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="],
"@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="], "@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="],
@ -1626,10 +1608,6 @@
"@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
"@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="],
"@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="],
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="],
@ -2950,8 +2928,6 @@
"before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="],
"better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="],
@ -3050,8 +3026,6 @@
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
@ -3244,8 +3218,6 @@
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="],
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
@ -3364,8 +3336,6 @@
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
@ -3466,8 +3436,6 @@
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
"extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
@ -3550,7 +3518,7 @@
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
@ -3730,8 +3698,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="],
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
@ -3856,8 +3822,6 @@
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
"is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="],
"is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
@ -3872,8 +3836,6 @@
"is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="], "is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="],
"is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="],
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="], "is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
@ -4026,8 +3988,6 @@
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
"lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="],
"loglevelnext": ["loglevelnext@6.0.0", "", {}, "sha512-FDl1AI2sJGjHHG3XKJd6sG3/6ncgiGCQ0YkW46nxe7SfqQq6hujd9CvFXIXtkGBUN83KPZ2KSOJK8q5P0bSSRQ=="], "loglevelnext": ["loglevelnext@6.0.0", "", {}, "sha512-FDl1AI2sJGjHHG3XKJd6sG3/6ncgiGCQ0YkW46nxe7SfqQq6hujd9CvFXIXtkGBUN83KPZ2KSOJK8q5P0bSSRQ=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
@ -4242,8 +4202,6 @@
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@ -4378,8 +4336,6 @@
"opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="], "opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="],
"outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
"oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="], "oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="],
@ -4398,8 +4354,6 @@
"p-defer": ["p-defer@3.0.0", "", {}, "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw=="], "p-defer": ["p-defer@3.0.0", "", {}, "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw=="],
"p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="],
"p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="],
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
@ -4418,7 +4372,7 @@
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"pacote": ["pacote@21.5.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ=="], "pacote": ["pacote@21.5.0", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-VtZ0SB8mb5Tzw3dXDfVAIjhyVKUHZkS/ZH9/5mpKenwC9sFOXNI0JI7kEF7IMkwOnsWMFrvAZHzx1T5fmrp9FQ=="],
@ -4482,7 +4436,7 @@
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
@ -4626,8 +4580,6 @@
"read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="],
"read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="],
"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-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=="],
"readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
@ -4882,8 +4834,6 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="],
"spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="],
"spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="],
@ -4960,8 +4910,6 @@
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
@ -5008,8 +4956,6 @@
"temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="],
"term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="],
"terracotta": ["terracotta@1.1.0", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-kfQciWUBUBgYkXu7gh3CK3FAJng/iqZslAaY08C+k1Hdx17aVEpcFFb/WPaysxAfcupNH3y53s/pc53xxZauww=="], "terracotta": ["terracotta@1.1.0", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-kfQciWUBUBgYkXu7gh3CK3FAJng/iqZslAaY08C+k1Hdx17aVEpcFFb/WPaysxAfcupNH3y53s/pc53xxZauww=="],
"terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="],
@ -5178,7 +5124,7 @@
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
@ -5626,12 +5572,6 @@
"@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
"@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="],
"@changesets/parse/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="],
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
@ -5658,8 +5598,6 @@
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/osx-sign/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
"@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], "@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
@ -5692,14 +5630,6 @@
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@manypkg/find-root/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="],
"@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
@ -5920,8 +5850,6 @@
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"app-builder-lib/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], "app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"app-builder-lib/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "app-builder-lib/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@ -5942,8 +5870,6 @@
"astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"astro/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
"astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], "astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
@ -5962,8 +5888,6 @@
"builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"builder-util/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@ -5990,8 +5914,6 @@
"dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"dmg-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@ -6008,32 +5930,24 @@
"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/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"electron-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"electron-publish/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"electron-updater/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"esbuild-plugin-copy/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=="], "esbuild-plugin-copy/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=="],
"esbuild-plugin-copy/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], "execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
@ -6054,6 +5968,8 @@
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gitlab-ai-provider/openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="], "gitlab-ai-provider/openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="],
@ -6130,8 +6046,6 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
@ -6166,8 +6080,6 @@
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"read-cache/pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@ -6210,8 +6122,6 @@
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp-file/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
@ -6390,34 +6300,22 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@changesets/parse/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/fuses/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/notarize/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"@electron/osx-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/osx-sign/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/universal/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/windows-sign/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], "@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
@ -6492,10 +6390,6 @@
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@malept/flatpak-bundler/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"@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=="], "@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=="],
@ -6686,10 +6580,6 @@
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"app-builder-lib/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"app-builder-lib/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@ -6720,10 +6610,6 @@
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"builder-util/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"builder-util/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
@ -6734,42 +6620,22 @@
"dir-compare/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "dir-compare/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"dmg-builder/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"dmg-builder/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"dmg-builder/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "dmg-builder/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"electron-builder/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"electron-builder/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"electron-publish/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"electron-publish/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"electron-updater/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"electron-updater/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"esbuild-plugin-copy/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"esbuild-plugin-copy/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@ -6818,10 +6684,6 @@
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"temp-file/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"temp-file/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"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/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=="], "tw-to-css/tailwindcss/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
@ -7020,8 +6882,6 @@
"@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], "@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@ -7068,6 +6928,8 @@
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
@ -7162,8 +7024,6 @@
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@manypkg/find-root/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],

View file

@ -2,7 +2,7 @@
exact = true exact = true
# Only install newly resolved package versions published at least 3 days ago. # Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200 minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"] minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64"]
[test] [test]
root = "./do-not-run-tests-from-root" root = "./do-not-run-tests-from-root"

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-CVmV+ohq5cXz5p2Sm1Wou274CzDDFGLNIBHXyEeB3So=", "x86_64-linux": "sha256-5DhbOm/gs2mfjmNYdZHkr0ZopgSC2HcGN9/r1noGqhc=",
"aarch64-linux": "sha256-/Gdx6D3uTKhY3BRO5uk37U0hNOL1U3kJ9gM0c9yCnmY=", "aarch64-linux": "sha256-0dIKcqKmhrPhRpabnfM20wnqt/AkoCWDMgG9cQZ8P3o=",
"aarch64-darwin": "sha256-OKxBQmJVR8jRECHU4ZhYoPeCFcpWBz7mPTT0nNOBTtg=", "aarch64-darwin": "sha256-Sx3G63vORj69u2AOMDfpk6NU7fMgxsbYBh3jnGohNXI=",
"x86_64-darwin": "sha256-mpYUN58HQGd34PwsbSGqjU/OTL5jRltxv98c0ahy30I=" "x86_64-darwin": "sha256-4g2ydNayqNqWlBeQt90rUI6bY5dMvXP4OmQ6QWaJbuI="
} }
} }

View file

@ -13,9 +13,6 @@
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint", "lint": "oxlint",
"changeset": "changeset",
"version:http-recorder": "changeset version",
"release:http-recorder": "bun ./packages/http-recorder/script/publish.ts",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty", "postinstall": "bun run --cwd packages/core fix-node-pty",
@ -96,7 +93,6 @@
}, },
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@changesets/cli": "2.31.0",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",

View file

@ -32,6 +32,11 @@
"bun": "./src/pty/pty.bun.ts", "bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts", "node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts" "default": "./src/pty/pty.bun.ts"
},
"#fff": {
"bun": "./src/filesystem/fff.bun.ts",
"node": "./src/filesystem/fff.node.ts",
"default": "./src/filesystem/fff.bun.ts"
} }
}, },
"devDependencies": { "devDependencies": {
@ -81,6 +86,7 @@
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:", "@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:", "@lydell/node-pty": "catalog:",
"@ff-labs/fff-bun": "0.9.3",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1", "@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*",

View file

@ -0,0 +1,136 @@
import {
FileFinder,
type DirItem,
type DirSearchResult,
type FileItem,
type GrepCursor,
type GrepMatch,
type GrepResult,
type InitOptions,
type MixedItem,
type MixedSearchResult,
type SearchResult,
} from "@ff-labs/fff-bun"
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export type Init = InitOptions
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export interface Grep {
items: GrepResult["items"]
totalMatched: number
totalFilesSearched: number
totalFiles: number
filteredFileCount: number
nextCursor: Cursor
regexFallbackError?: string
}
export interface Picker {
destroy(): void
isScanning(): boolean
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
glob(
pattern: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<DirSearch>
mixedSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<MixedSearch>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
pageSize?: number
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return FileFinder.isAvailable()
}
export function create(opts: Init): Result<Picker> {
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
isScanning: () => pick.isScanning(),
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
glob: (pattern, next) => pick.glob(pattern, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
grep: (query, next) => pick.grep(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
}
export * as Fff from "./fff.bun"

View file

@ -0,0 +1,138 @@
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export interface Init {
basePath: string
frecencyDbPath?: string
historyDbPath?: string
useUnsafeNoLock?: boolean
disableMmapCache?: boolean
disableContentIndexing?: boolean
disableWatch?: boolean
aiMode?: boolean
logFilePath?: string
logLevel?: "trace" | "debug" | "info" | "warn" | "error"
enableFsRootScanning?: boolean
enableHomeDirScanning?: boolean
}
export interface File {
relativePath: string
fileName: string
modified: number
}
export interface Directory {
relativePath: string
dirName: string
maxAccessFrecency: number
}
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
export interface Search {
items: File[]
scores: Array<{ total: number }>
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: Directory[]
scores: Array<{ total: number }>
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: Mixed[]
scores: Array<{ total: number }>
totalMatched: number
totalFiles: number
totalDirs: number
}
export type Cursor = null
export interface Hit {
relativePath: string
fileName: string
lineNumber: number
byteOffset: number
lineContent: string
matchRanges: [number, number][]
contextBefore?: string[]
contextAfter?: string[]
}
export interface Grep {
items: Hit[]
totalMatched: number
totalFilesSearched: number
totalFiles: number
filteredFileCount: number
nextCursor: Cursor
regexFallbackError?: string
}
export interface Picker {
destroy(): void
isScanning(): boolean
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
glob(
pattern: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<DirSearch>
mixedSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<MixedSearch>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
pageSize?: number
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
export function available() {
return false
}
export function create(_opts: Init): Result<Picker> {
return { ok: false, error: "fff unavailable on node runtime" }
}
export * as Fff from "./fff.node"

View file

@ -0,0 +1,553 @@
import path from "path"
import { Context, Deferred, Effect, Layer, Option, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FSUtil } from "../fs-util"
import { Glob } from "../util/glob"
import { Global } from "../global"
import * as Log from "../util/log"
import { serviceUse } from "../effect/service-use"
import { makeRuntime } from "../effect/runtime"
import { Fff } from "#fff"
import { Ripgrep } from "./ripgrep"
const log = Log.create({ service: "file.search" })
const root = path.join(Global.Path.cache, "fff")
export type Item = Ripgrep.Item
export type SearchError = PlatformError | globalThis.Error
export interface Result {
readonly items: Item[]
readonly partial: boolean
readonly hasNextPage: boolean
readonly engine: "fff" | "ripgrep"
readonly regexFallbackError?: string
}
export interface FileInput {
readonly cwd: string
readonly query: string
readonly limit?: number
readonly current?: string
readonly kind?: "file" | "directory" | "all"
}
export interface GlobInput {
readonly cwd: string
readonly pattern: string
readonly limit?: number
readonly signal?: AbortSignal
}
interface Query {
readonly dir: string
readonly text: string
readonly files: string[]
}
// A created picker plus its cached scan-readiness gate. The picker is created
// (and its native background scan kicked off) eagerly; `ready` is only awaited
// when the picker is actually used.
interface Picker {
readonly pick: Fff.Picker
readonly ready: Effect.Effect<void, Error>
}
interface State {
readonly pick: Map<string, Picker>
readonly wait: Map<string, Deferred.Deferred<Picker, Error>>
readonly recent: Query[]
}
export interface Interface {
readonly files: Ripgrep.Interface["files"]
readonly tree: Ripgrep.Interface["tree"]
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, SearchError>
readonly file: (input: FileInput) => Effect.Effect<string[] | undefined, SearchError>
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError>
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void, SearchError>
readonly warm: (cwd: string) => Effect.Effect<void>
// Destroy the picker for a directory and drop its cached state. Called when a
// directory's instance is disposed so fff's native watcher thread is torn
// down instead of leaking until process exit.
readonly release: (cwd: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Search") {}
export const use = serviceUse(Service)
function key(dir: string) {
return Buffer.from(dir).toString("base64url")
}
function fffSync<A>(action: string, run: () => A) {
return Effect.try({
try: run,
catch: (cause) => new Error(`fff ${action} failed`, { cause }),
})
}
function normalize(text: string) {
return text.replaceAll("\\", "/")
}
// fff supports glob narrowing for any search out of the box
function fffGlobbedQuery(query: string, glob?: string | string[]) {
if (query && glob) {
const resolvedGlob = Array.isArray(glob) ? glob.join(" ") : glob
return `${resolvedGlob} ${query}`
}
return query ?? glob
}
function remember(state: State, dir: string, text: string, files: string[]) {
if (!files.length) return
const next = Array.from(new Set(files.map(FSUtil.resolve))).slice(0, 64)
if (!next.length) return
const idx = state.recent.findIndex((item) => item.dir === dir && item.text === text)
if (idx >= 0) state.recent.splice(idx, 1)
state.recent.unshift({ dir, text, files: next })
if (state.recent.length > 32) state.recent.length = 32
}
function item(hit: Fff.Hit): Item {
const line = Buffer.from(hit.lineContent)
return {
path: { text: normalize(hit.relativePath) },
lines: { text: hit.lineContent },
line_number: hit.lineNumber,
absolute_offset: hit.byteOffset,
submatches: hit.matchRanges
.map(([start, end]) => {
const text = line.subarray(start, end).toString("utf8")
if (!text) return undefined
return {
match: { text },
start,
end,
}
})
.filter((row): row is Item["submatches"][number] => Boolean(row)),
}
}
function collectPaths<T>(
out: { items: T[]; scores: Array<{ total: number }> },
toPath: (item: T) => string,
opts?: { includeZeroScore?: boolean },
): string[] {
return Array.from(
new Set(
out.items.flatMap((item, idx): string[] => {
const score = out.scores[idx]
if (!score || (!opts?.includeZeroScore && score.total <= 0)) return []
const text = toPath(item)
if (!text) return []
return [text]
}),
),
)
}
function searchFff(
pick: Fff.Picker,
kind: "file" | "directory" | "all",
query: string,
opts: { currentFile?: string; pageIndex?: number; pageSize?: number },
): Fff.Result<string[]> {
if (kind === "directory") {
const out = pick.directorySearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
}
}
if (kind === "all") {
const out = pick.mixedSearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value, (entry) => normalize(entry.item.relativePath), { includeZeroScore: !query }),
}
}
const out = pick.fileSearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
}
}
export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const rg = yield* Ripgrep.Service
const state: State = {
pick: new Map<string, Picker>(),
wait: new Map<string, Deferred.Deferred<Picker, Error>>(),
recent: [] as Query[],
}
yield* fs.ensureDir(root).pipe(Effect.ignore)
yield* Effect.addFinalizer(() =>
Effect.forEach(
state.pick.values(),
(entry) => fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore),
{ discard: true },
),
)
const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
const out = yield* rg.search(input)
return {
items: out.items,
partial: out.partial,
hasNextPage: false,
engine: "ripgrep" as const,
}
})
// Lazy, shared scan-wait for a picker. Preserves the original behavior: if
// the scan does not finish within the budget the picker is destroyed and
// dropped from the cache so callers fall back to ripgrep (and the next
// request recreates a fresh picker).
const scanReady = (dir: string, pick: Fff.Picker) =>
Effect.gen(function* () {
const scanned = yield* Effect.tryPromise({
try: () => pick.waitForScan(5_000),
catch: (cause) => new Error("fff waitForScan failed", { cause }),
})
if (!scanned.ok || !scanned.value) {
yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
state.pick.delete(dir)
log.warn("fff scan not ready", { dir })
return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error))
}
const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error })
})
// Create (or return) the picker for a directory. Creation is synchronous
// and does not await the scan; the native background scan starts as soon as
// the picker exists. The `wait` gate dedupes concurrent creation.
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
// The opencode test runtime owns an isolated XDG tree that Windows must
// remove before process exit, so use ripgrep instead of native FFF there.
if (process.env.OPENCODE_TEST_HOME) return undefined
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
Effect.catch((error) => {
log.warn("fff availability check failed", { error })
return Effect.succeed(false)
}),
)
if (!available) return undefined
const dir = FSUtil.resolve(cwd)
const existing = state.pick.get(dir)
if (existing) return existing
const pending = state.wait.get(dir)
if (pending) return yield* Deferred.await(pending)
const gate = yield* Deferred.make<Picker, Error>()
state.wait.set(dir, gate)
return yield* Effect.gen(function* () {
const id = key(dir)
const isFirstPicker = state.pick.size === 0
const made = yield* fffSync("create picker", () =>
Fff.create({
basePath: dir,
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
historyDbPath: path.join(root, `${id}.history.mdb`),
// fff uses a bit different log version, also with spans so keep
// them in the same folder for debuggability
logFilePath: path.join(Global.Path.log, "fff.log"),
logLevel: Log.getLevel().toLowerCase() as Lowercase<Log.Level>,
aiMode: true,
// only the first toolcall picker can accumulate resources to index
// home directory, if the user specifically opened opencode at the
// $HOME level or asked it to search there on purpose, otherwise fallback
enableHomeDirScanning: isFirstPicker,
// on unix system it is 99.9% that you do not need to search for the
// content at the / so make fff fail creation and fallback to rg
enableFsRootScanning: isFirstPicker && process.platform === "win32",
}),
)
if (!made.ok) {
log.warn("fff init failed", { dir, error: made.error })
const err = new Error(made.error)
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
}
const pick = made.value
const entry: Picker = { pick, ready: yield* Effect.cached(scanReady(dir, pick)) }
state.pick.set(dir, entry)
yield* Deferred.succeed(gate, entry)
return entry
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
if (state.wait.get(dir) === gate) state.wait.delete(dir)
yield* Deferred.fail(gate, new Error("fff init interrupted")).pipe(Effect.ignore)
}),
),
)
})
// Resolve a usable, scanned picker for a directory, or undefined when fff is
// unavailable or the scan did not become ready.
const picker = Effect.fn("Search.picker")(function* (cwd: string) {
const entry = yield* acquire(cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
if (!entry) return undefined
const ready = yield* entry.ready.pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
if (!ready) return undefined
return entry.pick
})
const files: Interface["files"] = (input) => rg.files(input)
const tree: Interface["tree"] = (input) => rg.tree(input)
// in 99% of use cases user that is opened opencode at certain directory will
// conduct a file search in this direcotry, it could be switched later but
// mostly always we will need a file picker for cwd
// so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
const warm: Interface["warm"] = Effect.fn("Search.warm")(function* (cwd) {
yield* acquire(cwd).pipe(Effect.ignore)
})
// Tear down the picker for a directory. fff pickers own a native background
// watcher thread that otherwise lives until the runtime scope closes (i.e.
// process exit), so disposing the instance that warmed it must destroy it
// here or the thread leaks against a directory that may already be gone.
const release: Interface["release"] = Effect.fn("Search.release")(function* (cwd) {
const dir = FSUtil.resolve(cwd)
const pending = state.wait.get(dir)
if (pending) {
state.wait.delete(dir)
yield* Deferred.fail(pending, new Error("fff picker released")).pipe(Effect.ignore)
}
const entry = state.pick.get(dir)
if (entry) {
state.pick.delete(dir)
yield* fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore)
}
const remaining = state.recent.filter((item) => item.dir !== dir)
state.recent.splice(0, state.recent.length, ...remaining)
})
const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
const query = input.query.trim()
const kind = input.kind ?? "file"
const pick = yield* picker(input.cwd)
if (!pick) return undefined
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const fffResult = yield* fffSync(`${kind} search`, () =>
searchFff(pick, kind, query, {
pageIndex: 0,
currentFile: input.current, // supports both relative and absolute (relative preferred)
pageSize: limit,
}),
).pipe(
Effect.catch((error) => {
log.warn(`fff ${kind} search failed`, { dir, query, error })
return Effect.succeed<Fff.Result<string[]> | undefined>(undefined)
}),
)
if (!fffResult) return undefined
if (!fffResult.ok) {
log.warn(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
return undefined
}
const rows = fffResult.value
remember(
state,
dir,
query,
rows.map((row) => path.join(dir, row)),
)
return rows.slice(0, limit)
})
const search: Interface["search"] = Effect.fn("Search.search")(function* (input) {
input.signal?.throwIfAborted()
if (input.file?.length) return yield* rip(input)
const pick = yield* picker(input.cwd)
if (!pick) return yield* rip(input)
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const fffGrep = yield* fffSync("grep", () =>
pick.grep(fffGlobbedQuery(input.pattern, input.glob), {
mode: "regex",
pageSize: limit,
timeBudgetMs: 1_500,
}),
).pipe(
Effect.catch((error) => {
log.warn("fff grep failed", { dir, pattern: input.pattern, error })
return Effect.succeed<Fff.Result<Fff.Grep> | undefined>(undefined)
}),
)
if (!fffGrep) return yield* rip(input)
if (!fffGrep.ok) {
log.warn("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
return yield* rip(input)
}
const rows: Item[] = fffGrep.value.items.map(item)
const regexFallbackError = fffGrep.value.regexFallbackError
remember(state, dir, input.pattern, Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))))
return {
items: rows,
partial: false,
hasNextPage: !!fffGrep.value.nextCursor,
engine: "fff" as const,
regexFallbackError,
}
})
const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
input.signal?.throwIfAborted()
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const pick = yield* picker(dir)
if (pick) {
const fffGlob = yield* fffSync("glob file search", () =>
pick.glob(normalize(input.pattern), {
pageIndex: 0,
pageSize: limit,
}),
).pipe(
Effect.catch((error) => {
log.warn("fff glob failed", { dir, pattern: input.pattern, error })
return Effect.succeed<Fff.Result<Fff.Search> | undefined>(undefined)
}),
)
if (fffGlob?.ok) {
const rows: string[] = Array.from(new Set(fffGlob.value.items.map((item) => normalize(item.relativePath))))
remember(
state,
dir,
input.pattern,
rows.map((row) => path.join(dir, row)),
)
return {
files: rows.slice(0, limit).map((row) => path.join(dir, row)),
truncated: fffGlob.value.totalMatched > rows.length,
}
} else if (fffGlob) {
log.warn("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
// fall through to the fallback
}
}
const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe(
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > limit
if (truncated) rows.length = limit
const output = yield* Effect.forEach(
rows,
Effect.fnUntraced(function* (file) {
const full = path.join(dir, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const time =
info?.mtime.pipe(
Option.map((item) => item.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { file: full, time }
}),
{ concurrency: 16 },
)
output.sort((a, b) => b.time - a.time)
return {
files: output.map((item) => item.file),
truncated,
}
})
const open: Interface["open"] = Effect.fn("Search.open")(function* (input) {
const file = input.cwd
? FSUtil.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
: FSUtil.resolve(input.file)
const idx = state.recent.findIndex((item) => item.files.includes(file))
if (idx < 0) return
const row = state.recent[idx]
state.recent.splice(idx, 1)
const entry = state.pick.get(row.dir)
if (!entry) return
const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe(
Effect.catch((error) => {
log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error })
return Effect.succeed<Fff.Result<boolean> | undefined>(undefined)
}),
)
if (!out) return
if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
})
return Service.of({ files, tree, search, file, glob, open, warm, release })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export function tree(input: Ripgrep.TreeInput) {
return runPromise((svc) => svc.tree(input))
}
export function search(input: Ripgrep.SearchInput) {
return runPromise((svc) => svc.search(input))
}
export function file(input: FileInput) {
return runPromise((svc) => svc.file(input))
}
export function glob(input: GlobInput) {
return runPromise((svc) => svc.glob(input))
}
export function open(input: { cwd?: string; file: string }) {
return runPromise((svc) => svc.open(input))
}
export * as Search from "./search"

View file

@ -0,0 +1,72 @@
export * as Image from "./image"
import { Context, Effect, Layer, Schema } from "effect"
import { Config } from "./config"
import { FileSystem } from "./filesystem"
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
"Image.ResizerUnavailableError",
{},
) {}
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("Image.DecodeError", {
resource: Schema.String,
}) {
override get message() {
return `Image could not be decoded: ${this.resource}`
}
}
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("Image.SizeError", {
resource: Schema.String,
width: Schema.Number,
height: Schema.Number,
bytes: Schema.Number,
maxWidth: Schema.Number,
maxHeight: Schema.Number,
maxBytes: Schema.Number,
}) {
override get message() {
return `Image ${this.resource} is ${this.width}x${this.height} with base64 size ${this.bytes}, exceeding configured limits ${this.maxWidth}x${this.maxHeight}/${this.maxBytes} bytes`
}
}
export interface Interface {
readonly normalize: (
resource: string,
content: FileSystem.BinaryContent,
) => Effect.Effect<FileSystem.BinaryContent, ResizerUnavailableError | DecodeError | SizeError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const loadAdapter = yield* Effect.cached(
Effect.tryPromise({
try: () => import("./image/photon"),
catch: () => new ResizerUnavailableError(),
}).pipe(Effect.flatMap((adapter) => adapter.make)),
)
const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) {
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const normalize = yield* loadAdapter
return yield* normalize(resource, content, {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? 2_000,
maxHeight: image.max_height ?? 2_000,
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
})
})
return Service.of({ normalize })
}),
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))

View file

@ -0,0 +1,94 @@
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
export const make = Effect.gen(function* () {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
const loadPhoton = yield* Effect.cached(
Effect.tryPromise({
try: () => import("@silvia-odwyer/photon-node"),
catch: () => new ResizerUnavailableError(),
}),
)
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.BinaryContent,
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(content.content, "base64")),
catch: () => new DecodeError({ resource }),
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(content.content, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes) return content
if (!limits.autoResize)
return yield* new SizeError({
resource,
width,
height,
bytes,
maxWidth: limits.maxWidth,
maxHeight: limits.maxHeight,
maxBytes: limits.maxBase64Bytes,
})
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const encoders: Array<readonly [mime: string, encode: () => Uint8Array]> = [
["image/png", () => resized.get_bytes()],
...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const),
]
for (const [mime, encode] of encoders) {
const candidate = Buffer.from(encode()).toString("base64")
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime })
}
} finally {
resized.free()
}
}
return yield* new SizeError({
resource,
width,
height,
bytes,
maxWidth: limits.maxWidth,
maxHeight: limits.maxHeight,
maxBytes: limits.maxBase64Bytes,
})
} finally {
decoded.free()
}
})
})

View file

@ -28,6 +28,7 @@ import { Pty } from "./pty"
import { SkillV2 } from "./skill" import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance" import { SkillGuidance } from "./skill/guidance"
import { BuiltInTools } from "./tool/builtins" import { BuiltInTools } from "./tool/builtins"
import { Image } from "./image"
import { ToolRegistry } from "./tool/registry" import { ToolRegistry } from "./tool/registry"
import { ApplicationTools } from "./tool/application-tools" import { ApplicationTools } from "./tool/application-tools"
import { ToolOutputStore } from "./tool-output-store" import { ToolOutputStore } from "./tool-output-store"
@ -71,6 +72,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(base), Layer.provide(base),
) )
const services = Layer.mergeAll(base, resources, permissionsAndTools) const services = Layer.mergeAll(base, resources, permissionsAndTools)
const image = Image.layer.pipe(Layer.provide(services))
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services)) const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services)) const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services)) const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
@ -83,6 +85,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(resources), Layer.provide(resources),
Layer.provide(todos), Layer.provide(todos),
Layer.provide(questions), Layer.provide(questions),
Layer.provide(image),
) )
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services)) const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
const runner = SessionRunnerLLM.defaultLayer.pipe( const runner = SessionRunnerLLM.defaultLayer.pipe(
@ -90,9 +93,18 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(model), Layer.provide(model),
Layer.provide(skillGuidance), Layer.provide(skillGuidance),
) )
return Layer.mergeAll(services, mutation, searches, resources, todos, questions, model, runner, builtInTools).pipe( return Layer.mergeAll(
Layer.fresh, services,
) image,
mutation,
searches,
resources,
todos,
questions,
model,
runner,
builtInTools,
).pipe(Layer.fresh)
}, },
idleTimeToLive: "60 minutes", idleTimeToLive: "60 minutes",
dependencies: [ dependencies: [

View file

@ -303,7 +303,7 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
"type": "remote", "type": "remote",
"url": "https://...", "url": "https://...",
"enabled": true, "enabled": true,
"headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } "headers": { "Authorization": "Bearer {env:GITHUB_TOKEN}" }
}, },
"old-server": { "enabled": false } "old-server": { "enabled": false }
} }
@ -311,7 +311,9 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
``` ```
`command` is an array of strings. `type` is required. Use `enabled: false` to `command` is an array of strings. `type` is required. Use `enabled: false` to
disable a server inherited from a parent config. disable a server inherited from a parent config. String values such as header
tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style
`${VAR}` is not substituted.
## Permissions ## Permissions

View file

@ -17,7 +17,7 @@ import { Tool } from "./tool"
export interface Interface { export interface Interface {
readonly sessions: Session.Interface readonly sessions: Session.Interface
readonly tools: Tool.Service readonly tools: Tool.Interface
} }
/** Intentional public native API for Effect applications embedding OpenCode. */ /** Intentional public native API for Effect applications embedding OpenCode. */
@ -88,7 +88,7 @@ export const layer = Layer.effect(
const tools = yield* ApplicationTools.Service const tools = yield* ApplicationTools.Service
const validation = yield* SessionModelValidation const validation = yield* SessionModelValidation
return Service.of({ return Service.of({
tools: { attach: tools.attach }, tools: { register: tools.register },
sessions: { sessions: {
create: (input) => create: (input) =>
sessions.create({ sessions.create({

View file

@ -1,17 +1,17 @@
export * as Tool from "./tool" export * as Tool from "./tool"
import { Effect, Scope } from "effect" import { Effect, Scope } from "effect"
import type { NativeTool } from "../tool/native" import type { AnyTool, RegistrationError } from "../tool/tool"
export { Failure, make } from "../tool/native" export { Failure, RegistrationError, make } from "../tool/tool"
export type { Any, Content, Context, Executable } from "../tool/native" export type { AnyTool, Content, Context, Definition } from "../tool/tool"
export interface Service { export interface Interface {
/** /**
* Attach same-process tools to this OpenCode instance for the current Scope. * Register same-process tools on this OpenCode instance for the current Scope.
* Location tools with the same name take precedence where they are installed. * Location tools with the same name take precedence where they are installed.
* Closing the Scope removes the tools immediately, so calls that have not * Closing the Scope removes the tools immediately, so calls that have not
* started settling may fail because the tool is no longer available. * started settling may fail because the tool is no longer available.
*/ */
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope> readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
} }

View file

@ -7,6 +7,7 @@ import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model" import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context/index" import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch" import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store"
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()( export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"SessionRunner.StepLimitExceededError", "SessionRunner.StepLimitExceededError",
@ -24,6 +25,7 @@ export type RunError =
| StepLimitExceededError | StepLimitExceededError
| SystemContext.InitializationBlocked | SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked | SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */ /** Runs one local continuation from already-recorded Session history. */
export interface Interface { export interface Interface {

View file

@ -20,6 +20,7 @@ import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry" import { SystemContextRegistry } from "../../system-context/registry"
import { SkillGuidance } from "../../skill/guidance" import { SkillGuidance } from "../../skill/guidance"
import { ToolRegistry } from "../../tool/registry" import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextEpoch } from "../context-epoch" import { SessionContextEpoch } from "../context-epoch"
import { SessionCompaction } from "../compaction" import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event" import { SessionEvent } from "../event"
@ -63,7 +64,7 @@ import { toLLMMessages } from "./to-llm-message"
* - [x] Authorize and execute recorded local calls through a core-owned registry hook. * - [x] Authorize and execute recorded local calls through a core-owned registry hook.
* - [x] Persist typed success, failure, and provider-executed tool outcomes. * - [x] Persist typed success, failure, and provider-executed tool outcomes.
* - [x] Start each recorded local call eagerly and await all settlements before continuation. * - [x] Start each recorded local call eagerly and await all settlements before continuation.
* - [ ] Add scoped runtime context, progress updates, output truncation, attachment normalization, * - [ ] Add scoped runtime context, progress updates, attachment normalization,
* plugins, and cancellation settlement. * plugins, and cancellation settlement.
* - [x] Reload projected history and start the next explicit provider turn after local tool results. * - [x] Reload projected history and start the next explicit provider turn after local tool results.
* - [x] Continue for durable user steering accepted during an active provider turn. * - [x] Continue for durable user steering accepted during an active provider turn.
@ -131,7 +132,7 @@ export const layer = Layer.effect(
} }
}) })
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, never>) => const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers)) Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output. // Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
@ -185,7 +186,7 @@ export const layer = Layer.effect(
session.location, session.location,
agent.id, agent.id,
).pipe(retryAgentMismatch(promotion)) ).pipe(retryAgentMismatch(promotion))
const toolFibers = yield* FiberSet.make<void, never>() const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false let needsContinuation = false
if (promotion) { if (promotion) {
const cutoff = yield* SessionInput.latestSeq(db, session.id) const cutoff = yield* SessionInput.latestSeq(db, session.id)
@ -211,6 +212,7 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session) const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message) const context = entries.map((entry) => entry.message)
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({ const request = LLM.request({
model, model,
@ -219,7 +221,7 @@ export const layer = Layer.effect(
.filter((part): part is string => part !== undefined && part.length > 0) .filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make), .map(SystemPart.make),
messages: toLLMMessages(context, model), messages: toLLMMessages(context, model),
tools: yield* tools.definitions(agent.info?.permissions), tools: toolMaterialization.definitions,
}) })
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn()) return yield* Effect.die(rebuildPreparedTurn())
@ -251,16 +253,16 @@ export const layer = Layer.effect(
yield* publish(event) yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return if (event.type !== "tool-call" || event.providerExecuted) return
needsContinuation = true needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) => yield* Effect.uninterruptibleMask((restore) =>
restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe( restore(
Effect.catchCause((cause) => { toolMaterialization.settle({
if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause) sessionID: session.id,
return Effect.succeed({ agent: agent.id,
result: { type: "error" as const, value: String(Cause.squash(cause)) }, assistantMessageID,
output: undefined, call: event,
outputPaths: [],
})
}), }),
).pipe(
Effect.flatMap((settlement) => Effect.flatMap((settlement) =>
publish( publish(
LLMEvent.toolResult({ LLMEvent.toolResult({
@ -318,12 +320,17 @@ export const layer = Layer.effect(
yield* FiberSet.clear(toolFibers) yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
} }
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
const message = failure instanceof Error ? failure.message : String(failure)
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
}
if (publisher.hasProviderError()) if (publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError()) if (stream._tag === "Success" && !publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
const attempt = stream._tag === "Failure" ? stream : settled if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (attempt._tag === "Failure") return yield* Effect.failCause(attempt.cause) if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
return !publisher.hasProviderError() && needsContinuation return !publisher.hasProviderError() && needsContinuation
}), }),
) )

View file

@ -218,6 +218,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
} }
}) })
const assistantMessageIDForTool = (callID: string) => {
const tool = tools.get(callID)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent, event: LLMEvent,
outputPaths: ReadonlyArray<string> = [], outputPaths: ReadonlyArray<string> = [],
@ -408,5 +413,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
hasAssistantStarted: () => assistantMessageID !== undefined, hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed, hasProviderError: () => providerFailed,
startAssistant, startAssistant,
assistantMessageID: assistantMessageIDForTool,
} }
} }

View file

@ -61,30 +61,40 @@ export function create<State extends Objectish, Editor>(options: Options<State,
state = next state = next
}) })
const rebuild = Effect.fn("State.rebuild")(function* () { const rebuild = Effect.fnUntraced(function* () {
const next = options.initial() const next = options.initial()
const api = options.editor(next as Draft<State>) const api = options.editor(next as Draft<State>)
for (const transform of transforms) for (const transform of transforms)
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {})) yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
yield* commit(next) yield* commit(next)
}, semaphore.withPermit) })
return { return {
get: () => state, get: () => state,
transform: Effect.fn("State.transform")(function* () { transform: Effect.fn("State.transform")(function* () {
const transform = { update: (_editor: Editor) => {} }
transforms = [...transforms, transform]
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
yield* Scope.addFinalizer( return yield* Effect.uninterruptible(
scope, Effect.gen(function* () {
Effect.sync(() => { const transform = { update: (_editor: Editor) => {} }
transforms = transforms.filter((item) => item !== transform) transforms = [...transforms, transform]
}).pipe(Effect.andThen(rebuild())), yield* Scope.addFinalizer(
scope,
semaphore.withPermit(
Effect.sync(() => {
transforms = transforms.filter((item) => item !== transform)
}).pipe(Effect.andThen(rebuild())),
),
)
return (update: Transform<Editor>) =>
Effect.uninterruptible(
semaphore.withPermit(
Effect.sync(() => {
transform.update = update
}).pipe(Effect.andThen(rebuild())),
),
)
}),
) )
return Effect.fnUntraced(function* (update: Transform<Editor>) {
transform.update = update
yield* rebuild()
})
}), }),
update: Effect.fn("State.update")(function* (update, reason) { update: Effect.fn("State.update")(function* (update, reason) {
const api = options.editor(state as Draft<State>) const api = options.editor(state as Draft<State>)

View file

@ -1,7 +1,7 @@
export * as ToolOutputStore from "./tool-output-store" export * as ToolOutputStore from "./tool-output-store"
import path from "path" import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect" import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Config } from "./config" import { Config } from "./config"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Global } from "./global" import { Global } from "./global"
@ -15,23 +15,6 @@ export const RETENTION = Duration.days(7)
export const MANAGED_DIRECTORY = "tool-output" export const MANAGED_DIRECTORY = "tool-output"
export interface WriteInput {
readonly sessionID: SessionSchema.ID
readonly toolCallID: string
readonly content: string
readonly mime?: string
readonly name?: string
}
export interface TruncateInput extends WriteInput {
readonly maxLines?: number
readonly maxBytes?: number
}
export type TruncateResult =
| { readonly content: string; readonly truncated: false }
| { readonly content: string; readonly truncated: true; readonly outputPath: string }
export interface BoundInput { export interface BoundInput {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly toolCallID: string readonly toolCallID: string
@ -43,11 +26,16 @@ export interface BoundResult {
readonly outputPaths: ReadonlyArray<string> readonly outputPaths: ReadonlyArray<string>
} }
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
operation: Schema.Literals(["encode", "write"]),
cause: Schema.Defect,
}) {}
export type Error = StorageError
export interface Interface { export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }> readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly write: (input: WriteInput) => Effect.Effect<string> readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult>
readonly cleanup: () => Effect.Effect<void> readonly cleanup: () => Effect.Effect<void>
} }
@ -109,6 +97,12 @@ const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes
return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}` return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`
} }
const lineCount = (text: string) => {
let count = 1
for (const char of text) if (char === "\n") count++
return count
}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@ -116,7 +110,6 @@ export const layer = Layer.effect(
const global = yield* Global.Service const global = yield* Global.Service
const config = yield* Effect.serviceOption(Config.Service) const config = yield* Effect.serviceOption(Config.Service)
const directory = path.join(global.data, MANAGED_DIRECTORY) const directory = path.join(global.data, MANAGED_DIRECTORY)
const limits = Effect.fn("ToolOutputStore.limits")(function* () { const limits = Effect.fn("ToolOutputStore.limits")(function* () {
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES } if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[]))) const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
@ -127,68 +120,50 @@ export const layer = Layer.effect(
return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES } return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
}) })
const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) { const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
const file = path.join(directory, `tool_${Identifier.ascending()}`) const file = path.join(directory, `tool_${Identifier.ascending()}`)
yield* fs.ensureDir(directory).pipe(Effect.orDie) yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie) yield* fs
.writeFileString(file, content, { flag: "wx" })
.pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
return file return file
}) })
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
const configured = yield* limits()
const maxLines = input.maxLines ?? configured.maxLines
const maxBytes = input.maxBytes ?? configured.maxBytes
if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
return { content: input.content, truncated: false } as const
}
const outputPath = yield* write(input)
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
content: boundedPreview(input.content, marker, maxLines, maxBytes),
truncated: true,
outputPath,
} as const
})
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) { const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const text = input.output.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n\n") const outputLimits = yield* limits()
const structured = yield* Effect.sync(() => JSON.stringify(input.output.structured)).pipe( const media = input.output.content.filter((item) => item.type === "file")
Effect.catch(() => Effect.succeed(String(input.output.structured))), const text = input.output.content.filter((item) => item.type === "text")
const contextual =
input.output.content.length === 0
? yield* Effect.try({
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
: text.map((item) => item.text).join("")
if (
lineCount(contextual) <= outputLimits.maxLines &&
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
) )
const content = text || input.output.content.length > 0 ? text : structured return {
if (content === undefined) return { output: input.output, outputPaths: [] } output: input.output,
outputPaths: [],
}
const truncated = yield* truncate({ const outputPath = yield* write(contextual)
sessionID: input.sessionID, const marker = `... output truncated; full content saved to ${outputPath} ...`
toolCallID: input.toolCallID,
content,
mime: "text/plain",
name: `${input.toolCallID}.txt`,
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Unable to retain complete tool output", cause).pipe(
Effect.andThen(limits()),
Effect.map(({ maxLines, maxBytes }) => {
const marker = "... output truncated; omitted content could not be retained ..."
return {
content: boundedPreview(content, marker, maxLines, maxBytes),
truncated: true as const,
}
}),
),
),
)
if (!truncated.truncated) return { output: input.output, outputPaths: [] }
return { return {
output: { output: {
structured: input.output.structured, structured: input.output.structured,
content: [ content: [
{ type: "text" as const, text: truncated.content }, {
...input.output.content.filter((item) => item.type === "file"), type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
], ],
}, },
outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [], outputPaths: [outputPath],
} }
}) })
@ -207,7 +182,7 @@ export const layer = Layer.effect(
} }
}) })
return Service.of({ limits, write, truncate, bound, cleanup }) return Service.of({ limits, bound, cleanup })
}), }),
) )

View file

@ -1,139 +1,59 @@
# Core Tool Architecture # Core Tool Architecture
This folder owns Core-native tool definition, contribution, effective lookup, and execution. Keep those concerns distinct even though `ToolRegistry` brings them together at runtime. This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
## Current Architecture ## Representations
```txt - `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Application tools and shipped built-ins use the same type.
Public Tool.make NativeTool value ApplicationTools Location built-ins Location ToolRegistry Session runner - `application-tools.ts` stores process-scoped application registrations.
│ │ │ │ │ │ - `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
├─ construct ─────────▶ │ │ │ │ - `registry.ts` stores only canonical tools, overlays Location registrations over application registrations, derives definitions, invokes tools, and applies generic output bounding.
│ │ │ │ │ │
│ ├─ scoped attach ─────▶ │ │ │ Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
│ │ │ │ │ │
│ │ │ ├─ scoped contributions ──▶ │ ## Construction
│ │ │ │ │ │
│ │ ├─ shared current entries ───────────────────────▶ │ Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
│ │ │ │ │ │
│ │ │ │ ├─ effective definitions and settlement ──▶ Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
│ │ │ │ │ │
```ts
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
``` ```
There are three relevant representations: Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive.
- `native.ts` defines the plain Core-native executable value exposed publicly as `Tool.make(...)`. It combines an `@opencode-ai/llm` model-facing definition with a Session-aware handler. ## Registration
- `application-tools.ts` stores process-scoped application contributions. It owns availability and scoped attachment, but it does not execute tools.
- `registry.ts` is the single execution registry. Each Location owns one registry, its built-in contributions, effective precedence, input/output validation, permissions, and settlement.
`ToolRegistry.Entry` is intentionally more powerful than the public native tool value. Internal Location tools may use Core-owned capabilities such as `assertPermission`; embedding applications receive only the narrow public execution context. Built-ins register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`.
## Placement And Layers Both are scoped:
- `ApplicationTools.Service` is process-scoped and must be shared by current and future Locations. - The latest active same-placement registration wins.
- `ToolRegistry.Service` is Location-scoped because built-in handlers close over Location services such as filesystem, permissions, and tool-output storage. - Closing any registration removes only that registration and reveals the next active one.
- `LocationServiceMap` constructs fresh Location services while receiving the shared `ApplicationTools.Service` as a dependency. - Location registrations take precedence over application registrations.
- `OpenCode.layer` exposes the same shared application-tool service through `opencode.tools.attach(...)`. - An invocation captures the effective tool once settlement starts.
- `ToolRegistry.defaultLayer` creates isolated application-tool state. It is suitable for self-contained consumers and tests, but not when attachments must be shared with a separately constructed `LocationServiceMap`.
Do not make `ToolRegistry` process-global. Do not move Location resources into `ApplicationTools`. Do not construct independent `ApplicationTools.layer` instances when the caller expects one attachment to appear across Locations. `ApplicationTools.Service` is process-scoped and shared by all Locations. `ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
## Contribution And Precedence ## Permissions
Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`. The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action.
Both contribution mechanisms use `State` scoped transforms: Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
- Closing a contribution Scope rebuilds state without that contribution. ## Output
- A later same-name application attachment wins while active.
- Closing that later attachment reveals the earlier active application contribution.
- A Location tool always takes precedence over an application tool with the same name.
- Application attachment inputs are captured before registering the replayable transform; later caller mutation must not alter a contribution during an unrelated rebuild.
Do not introduce another application-specific tool type or registry. Plugins should contribute existing native tools or internal registry entries at the lifetime they actually own. Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
## Dynamic Removal Semantics Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach. ## Current Gaps
```txt - Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations.
Embedding App ApplicationTools Location ToolRegistry Session Runner - MCP and future Session-scoped registrations still need an explicit canonical registration design.
│ │ │ │ - The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.
├─ attach({ opencord_run }) ──▶ │ │
│ │ │ │
│ │ ◀─ definitions() ──────────────────┤
│ │ │ │
│ ◀─ entries() ────────────┤ │
│ │ │ │
│ │ ├─ current effective definitions ──▶
│ │ │ │
├─ attachment Scope closes ───▶ │ │
│ │ │ │
│ │ ◀─ settle(opencord_run) ───────────┤
│ │ │ │
│ ◀─ current lookup ───────┤ │
│ │ │ │
│ │ ├─ Unknown tool ───────────────────▶
│ │ │ │
```
Consequences of this choice:
- Closing an attachment Scope revokes the tool immediately for calls that have not started settling.
- A call produced from an earlier advertised definition may fail as unknown.
- If a same-name replacement is currently active, a later call may execute that replacement.
- An execution that already resolved its entry continues with the handler it captured.
- Attachment Scope closure does not wait for already-started executions. Applications whose handlers depend on scoped resources must coordinate graceful shutdown themselves.
These are deliberate simplifications. Do not add snapshots, semaphores, leases, or deferred finalizers without a concrete requirement for stronger consistency or graceful draining.
## File Roles
```txt
tool/
native.ts plain public/Core-native executable tool value
application-tools.ts process-scoped State-backed application contributions
registry.ts Location-scoped effective lookup, validation, and execution
builtins.ts shipped Location tool layer composition
read.ts, bash.ts, ... individual Location-scoped built-in contributions
```
Keep model/provider-neutral tool schemas and output projection in `@opencode-ai/llm`. Keep Session identity, permissions, Location precedence, and settlement in Core.
## Future Directions
Tool availability may eventually gain a real third scope, such as Session-specific or plugin-owned contributions:
```txt
╭─────────────────╮
│ Tool definition │
╰────────┬────────╯
╭────────────────────────────────────────╰╮─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╮
│ │
▼ ▼ ▼
╭───────────────────────╮ ╭────────────────────────╮ ╭───────────────────────╮
│ Process contributions │ │ Location contributions │ │ Session contributions │
╰───────────┬───────────╯ ╰────────────┬───────────╯ ╰───────────┬───────────╯
│ │ │
│ │
╰─────────────────────────────────────────◀─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╯
╭──────────────────────╮
│ Effective resolution │
╭─────────╰───────────┬──────────╯────────────╮
│ │ │
▼ ▼
╭───────────────────────────────╮ ╭─────────────────────────╮
│ Advertise current definitions │ │ Execute current handler │
╰───────────────────────────────╯ ╰─────────────────────────╯
```
Prefer these directions only when a concrete use requires them:
- **Contextual availability:** Add Session/agent/plugin filtering at effective resolution. Keep tool definitions independent from where they are enabled.
- **Hierarchical overlays:** If a third contribution scope becomes real, consider one registry abstraction with process, Location, and Session overlays rather than adding another special registry service.
- **Plugin tools:** Reuse the existing native tool value for restricted handlers and `ToolRegistry.Entry` for trusted Core-owned capabilities. Choose process or Location contribution lifetime explicitly.
- **Stale-call rejection:** If executing a same-name replacement is unsafe, attach an identity/version to advertised definitions and reject stale calls without retaining removed handlers.
- **Pinned provider turns:** If exact advertisement-to-execution consistency becomes necessary, snapshot effective entries for one provider turn. This weakens immediate revocation.
- **Graceful plugin unload:** If attachment-owned resources must outlive started executions, add explicit execution draining. Keep this separate from whether new calls can discover the tool.
- **Cluster placement:** `ApplicationTools` is process-global, not cluster-global. Cluster-wide contribution and execution ownership require a separate durable design.
When choosing stronger semantics, state which property matters: immediate revocation, stale-call rejection, exact handler pinning, or graceful resource draining. They are different guarantees and should not arrive as one bundled lifecycle mechanism.

View file

@ -1,21 +1,28 @@
export * as ApplicationTools from "./application-tools" export * as ApplicationTools from "./application-tools"
import { Context, Effect, Layer, Scope } from "effect" import { Context, Effect, Layer, Scope } from "effect"
import { castDraft, enableMapSet } from "immer" import { enableMapSet } from "immer"
import { State } from "../state" import { State } from "../state"
import { NativeTool } from "./native" import { Tool } from "./tool"
type Data = { type Data = {
readonly entries: Map<string, NativeTool.Any> readonly entries: Map<string, Entry>
} }
type Editor = { type Editor = {
readonly set: (name: string, tool: NativeTool.Any) => void readonly set: (name: string, entry: Entry) => void
}
export interface Entry {
readonly identity: object
readonly tool: Tool.AnyTool
} }
export interface Interface { export interface Interface {
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope> readonly register: (
readonly entries: () => ReadonlyMap<string, NativeTool.Any> tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
readonly entries: () => ReadonlyMap<string, Entry>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
@ -29,20 +36,20 @@ export const layer = Layer.effect(
initial: () => ({ entries: new Map() }), initial: () => ({ entries: new Map() }),
editor: (draft) => ({ editor: (draft) => ({
set: (name, tool) => { set: (name, tool) => {
draft.entries.set( draft.entries.set(name, tool)
name,
castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
)
}, },
}), }),
}) })
return Service.of({ return Service.of({
attach: Effect.fn("ApplicationTools.attach")(function* (tools) { register: Effect.fn("ApplicationTools.register")(function* (tools) {
const entries = Object.entries(tools) const entries = Object.entries(tools)
if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const)
const transform = yield* state.transform() const transform = yield* state.transform()
yield* transform((editor) => { yield* transform((editor) => {
for (const [name, tool] of entries) editor.set(name, tool) for (const [name, entry] of registrations) editor.set(name, entry)
}) })
}), }),
entries: () => state.get().entries, entries: () => state.get().entries,

View file

@ -1,16 +1,18 @@
export * as ApplyPatchTool from "./apply-patch" export * as ApplyPatchTool from "./apply-patch"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch" import { Patch } from "../patch"
import { ToolRegistry } from "./registry" import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "apply_patch" export const name = "apply_patch"
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
patchText: Schema.String.annotate({ patchText: Schema.String.annotate({
description: "The full patch text describing add, update, and delete operations", description: "The full patch text describing add, update, and delete operations",
}), }),
@ -22,10 +24,10 @@ export const Applied = Schema.Struct({
target: Schema.String, target: Schema.String,
}) })
export const Success = Schema.Struct({ applied: Schema.Array(Applied) }) export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
export type Success = typeof Success.Type export type Output = typeof Output.Type
export const toModelOutput = (output: Success) => export const toModelOutput = (output: Output) =>
[ [
"Applied patch sequentially:", "Applied patch sequentially:",
...output.applied.map( ...output.applied.map(
@ -33,14 +35,6 @@ export const toModelOutput = (output: Success) =>
), ),
].join("\n") ].join("\n")
const definition = Tool.make({
description:
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
type Prepared = type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target }) | (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
| (Extract<Patch.Hunk, { readonly type: "update" }> & { | (Extract<Patch.Hunk, { readonly type: "update" }> & {
@ -51,116 +45,133 @@ type Prepared =
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.withPermission(
execute: ({ parameters, assertPermission }) => { Tool.make({
const applied: Array<typeof Applied.Type> = [] description:
const fail = (path: string, cause: unknown) => { "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
const prefix = input: Input,
applied.length === 0 output: Output,
? `Unable to apply patch at ${path}` toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` execute: (input, context) => {
return new ToolFailure({ message: prefix, error: cause }) const applied: Array<typeof Applied.Type> = []
} const fail = (path: string) => {
return Effect.gen(function* () { const prefix =
if (!parameters.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) applied.length === 0
const hunks = yield* Effect.try({ ? `Unable to apply patch at ${path}`
try: () => Patch.parse(parameters.patchText), : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), return new ToolFailure({ message: prefix })
}) }
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) return Effect.gen(function* () {
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) const source = {
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) type: "tool" as const,
messageID: context.assistantMessageID,
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] callID: context.toolCallID,
for (const hunk of hunks)
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { target } of targets) {
const external = target.externalDirectory
if (external) externalDirectories.set(external.resource, external)
}
for (const external of externalDirectories.values()) {
yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
}
yield* assertPermission({
action: "edit",
resources: [...new Set(targets.map(({ target }) => target.resource))],
save: ["*"],
})
const prepared: Prepared[] = []
for (const { hunk, target } of targets) {
yield* Effect.gen(function* () {
if (hunk.type === "add") {
prepared.push({ ...hunk, target })
return
} }
if ((yield* fs.stat(target.canonical)).type !== "File") if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
yield* fail(hunk.path, new Error("Target file does not exist")) const hunks = yield* Effect.try({
if (hunk.type === "delete") { try: () => Patch.parse(input.patchText),
prepared.push({ ...hunk, target }) catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
return
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
}) })
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(hunk.path, Cause.squash(cause))))) if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
} const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
yield* Effect.forEach( const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
prepared, for (const hunk of hunks)
(change) => targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
Effect.gen(function* () { const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
if (change.type === "add") { for (const { target } of targets) {
const result = yield* files.create({ const external = target.externalDirectory
target: change.target, if (external) externalDirectories.set(external.resource, external)
content: }
change.contents.endsWith("\n") || change.contents === "" for (const external of externalDirectories.values()) {
? change.contents yield* permission.assert({
: `${change.contents}\n`, ...LocationMutation.externalDirectoryPermission(external),
}) sessionID: context.sessionID,
applied.push({ type: change.type, resource: result.resource, target: result.target }) agent: context.agent,
return source,
}
if (change.type === "delete") {
const result = yield* files.remove({ target: change.target })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({
target: change.target,
expected: change.source,
content: change.content,
}) })
applied.push({ type: change.type, resource: result.resource, target: result.target }) }
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.path, Cause.squash(cause))))), yield* permission.assert({
{ discard: true }, action: "edit",
) resources: [...new Set(targets.map(({ target }) => target.resource))],
return { applied } save: ["*"],
}).pipe( sessionID: context.sessionID,
Effect.catchCause((cause) => { agent: context.agent,
const error = Cause.squash(cause) source,
return Effect.fail(error instanceof ToolFailure ? error : fail("patch", error)) })
}),
) const prepared: Prepared[] = []
}, for (const { hunk, target } of targets) {
}), yield* Effect.gen(function* () {
) if (hunk.type === "add") {
prepared.push({ ...hunk, target })
return
}
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
if (hunk.type === "delete") {
prepared.push({ ...hunk, target })
return
}
const source = yield* fs.readFile(target.canonical)
const update = Patch.derive(
hunk.path,
hunk.chunks,
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
)
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
})
}).pipe(Effect.mapError(() => fail(hunk.path)))
}
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({
target: change.target,
content:
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ target: change.target })
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
const result = yield* files.writeIfUnchanged({
target: change.target,
expected: change.source,
content: change.content,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.mapError(() => fail(change.path))),
{ discard: true },
)
return { applied }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
},
}),
"edit",
),
})
.pipe(Effect.orDie)
}), }),
) )

View file

@ -1,23 +1,24 @@
export * as BashTool from "./bash" export * as BashTool from "./bash"
import path from "path" import path from "path"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Duration, Effect, Layer, Schema } from "effect" import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config" import { Config } from "../config"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { AppProcess } from "../process" import { AppProcess } from "../process"
import { PermissionV2 } from "../permission"
import { PositiveInt } from "../schema" import { PositiveInt } from "../schema"
import { ToolOutputStore } from "../tool-output-store" import { Tool } from "./tool"
import { ToolRegistry } from "./registry" import { Tools } from "./tools"
export const name = "bash" export const name = "bash"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024 export const MAX_CAPTURE_BYTES = 1024 * 1024
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }), command: Schema.String.annotate({ description: "Shell command string to execute" }),
workdir: Schema.String.pipe(Schema.optional).annotate({ workdir: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.", description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
@ -32,7 +33,7 @@ export const Parameters = Schema.Struct({
}), }),
}) })
const Success = Schema.Struct({ const Output = Schema.Struct({
command: Schema.String, command: Schema.String,
cwd: Schema.String, cwd: Schema.String,
exitCode: Schema.Number.pipe(Schema.optional), exitCode: Schema.Number.pipe(Schema.optional),
@ -41,12 +42,11 @@ const Success = Schema.Struct({
truncated: Schema.Boolean, truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional), stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: Schema.Boolean.pipe(Schema.optional), stderrTruncated: Schema.Boolean.pipe(Schema.optional),
outputPath: Schema.String.pipe(Schema.optional),
timedOut: Schema.Boolean.pipe(Schema.optional), timedOut: Schema.Boolean.pipe(Schema.optional),
warnings: Schema.Array(Schema.String).pipe(Schema.optional), warnings: Schema.Array(Schema.String).pipe(Schema.optional),
}) })
type Success = typeof Success.Type type Output = typeof Output.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
@ -59,9 +59,10 @@ const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]" if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]" if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]" if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
return undefined
} }
const modelOutput = (output: Success) => { const modelOutput = (output: Output) => {
const warnings = output.warnings?.length const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: "" : ""
@ -72,13 +73,6 @@ const modelOutput = (output: Success) => {
const isTimeout = (error: AppProcess.AppProcessError) => const isTimeout = (error: AppProcess.AppProcessError) =>
error.cause instanceof Error && error.cause.message === "Timed out" error.cause instanceof Error && error.cause.message === "Timed out"
const definition = Tool.make({
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
})
/** /**
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the * Minimal V2 core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core. * legacy shell runtime into core.
@ -112,96 +106,101 @@ const externalCommandDirectories = (command: string, cwd: string) => {
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const appProcess = yield* AppProcess.Service const appProcess = yield* AppProcess.Service
const resources = yield* ToolOutputStore.Service
const config = yield* Config.Service const config = yield* Config.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.make({
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []), description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
execute: ({ parameters, sessionID, call, assertPermission }) => input: Input,
Effect.gen(function* () { output: Output,
const target = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" }) toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
const external = target.externalDirectory execute: (input, context) =>
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external)) Effect.gen(function* () {
const warnings = externalCommandDirectories(parameters.command, target.canonical).map( const source = {
(directory) => type: "tool" as const,
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, messageID: context.assistantMessageID,
) callID: context.toolCallID,
yield* assertPermission({ action: name, resources: [parameters.command], save: [parameters.command] })
if ((yield* fs.stat(target.canonical)).type !== "Directory")
throw new Error(`Working directory is not a directory: ${target.canonical}`)
const entries = yield* config.entries()
const shell =
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))).shell ??
defaultShell()
const command = ChildProcess.make(parameters.command, [], {
cwd: target.canonical,
shell,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
})
const timeout = parameters.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess
.run(command, {
timeout: Duration.millis(timeout),
maxOutputBytes: MAX_CAPTURE_BYTES,
maxErrorBytes: MAX_CAPTURE_BYTES,
})
.pipe(
Effect.catchTag("AppProcessError", (error) =>
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
),
)
if (!result) {
return {
command: parameters.command,
cwd: target.canonical,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timedOut: true,
...(warnings.length ? { warnings } : {}),
} }
} const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const warnings = externalCommandDirectories(input.command, target.canonical).map(
(directory) =>
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
)
yield* permission.assert({
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8")) if ((yield* fs.stat(target.canonical)).type !== "Directory")
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const truncated = yield* resources.truncate({
sessionID, const entries = yield* config.entries()
toolCallID: call.id, const shell =
content: notice ? `${compact}\n\n${notice}` : compact, Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : [])))
}) .shell ?? defaultShell()
return { const command = ChildProcess.make(input.command, [], {
command: parameters.command, cwd: target.canonical,
cwd: target.canonical, shell,
exitCode: result.exitCode, stdin: "ignore",
output: truncated.content, detached: process.platform !== "win32",
truncated: truncated.truncated || result.stdoutTruncated || result.stderrTruncated, forceKillAfter: Duration.seconds(3),
...(warnings.length ? { warnings } : {}), })
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
...(result.stderrTruncated ? { stderrTruncated: true } : {}), const result = yield* appProcess
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated .run(command, {
? { outputPath: truncated.outputPath } timeout: Duration.millis(timeout),
: {}), maxOutputBytes: MAX_CAPTURE_BYTES,
} maxErrorBytes: MAX_CAPTURE_BYTES,
}).pipe( })
Effect.catchCause((cause) => .pipe(
Effect.fail( Effect.catchTag("AppProcessError", (error) =>
new ToolFailure({ isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
message: `Unable to execute command: ${parameters.command}`, ),
error: Cause.squash(cause), )
}), if (!result) {
), return {
), command: input.command,
), cwd: target.canonical,
}), output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
) truncated: false,
timedOut: true,
...(warnings.length ? { warnings } : {}),
}
}
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
return {
command: input.command,
cwd: target.canonical,
exitCode: result.exitCode,
output: notice ? `${compact}\n\n${notice}` : compact,
truncated: result.stdoutTruncated || result.stderrTruncated,
...(warnings.length ? { warnings } : {}),
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}),
})
.pipe(Effect.orDie)
}), }),
) )

View file

@ -17,7 +17,7 @@ import { WriteTool } from "./write"
/** /**
* Composes only the shipped Location-scoped built-in tool contributions. * Composes only the shipped Location-scoped built-in tool contributions.
* Each tool retains its implementation and focused tests independently. Dynamic * Each tool retains its implementation and focused tests independently. Dynamic
* MCP and plugin tools later use separate scoped ToolRegistry transforms, while * MCP and plugin tools later use separate scoped canonical registrations, while
* provider/model filtering belongs to a future materialization phase rather * provider/model filtering belongs to a future materialization phase rather
* than this static list. The caller intentionally supplies shared Location * than this static list. The caller intentionally supplies shared Location
* services once to this merged set. * services once to this merged set.

View file

@ -7,16 +7,18 @@
*/ */
export * as EditTool from "./edit" export * as EditTool from "./edit"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { ToolRegistry } from "./registry" import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "edit" export const name = "edit"
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
path: Schema.String.annotate({ path: Schema.String.annotate({
description: description:
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.", "File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
@ -28,14 +30,14 @@ export const Parameters = Schema.Struct({
}), }),
}) })
export const Success = Schema.Struct({ export const Output = Schema.Struct({
operation: Schema.Literal("write"), operation: Schema.Literal("write"),
target: Schema.String, target: Schema.String,
resource: Schema.String, resource: Schema.String,
existed: Schema.Boolean, existed: Schema.Boolean,
replacements: Schema.Number, replacements: Schema.Number,
}) })
export type Success = typeof Success.Type export type Output = typeof Output.Type
const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n") const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n") const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
@ -68,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
return shown return shown
} }
export const toModelOutput = (output: Success, oldString: string, newString: string) => export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[ [
`Edited file successfully: ${output.resource}`, `Edited file successfully: ${output.resource}`,
`Replacements: ${output.replacements}`, `Replacements: ${output.replacements}`,
@ -78,16 +80,6 @@ export const toModelOutput = (output: Success, oldString: string, newString: str
"```", "```",
].join("\n") ].join("\n")
const definition = Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
parameters: Parameters,
success: Success,
toModelOutput: ({ parameters, output }) => [
toolText({ type: "text", text: toModelOutput(output, parameters.oldString, parameters.newString) }),
],
})
/** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */ /** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */
// TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review. // TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.
// TODO: Add formatter integration after V2 formatter runtime exists. // TODO: Add formatter integration after V2 formatter runtime exists.
@ -97,80 +89,112 @@ const definition = Tool.make({
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.withPermission(
execute: ({ parameters, assertPermission }) => { Tool.make({
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) => description:
effect.pipe( "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
Effect.catchCause((cause) => { input: Input,
const error = Cause.squash(cause) output: Output,
return Effect.fail( toModelOutput: ({ input, output }) => [
error instanceof FileMutation.StaleContentError toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
? new ToolFailure({ ],
message: "File changed after permission approval. Read it again before editing.", execute: (input, context) => {
}) const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
: new ToolFailure({ message: `Unable to edit ${parameters.path}`, error }), effect.pipe(
Effect.mapError((error) =>
error instanceof FileMutation.StaleContentError
? new ToolFailure({
message: "File changed after permission approval. Read it again before editing.",
})
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
),
) )
}),
)
return Effect.gen(function* () { return Effect.gen(function* () {
if (parameters.oldString === parameters.newString) { const permissionSource = {
return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." }) type: "tool" as const,
} messageID: context.assistantMessageID,
if (parameters.oldString === "") { callID: context.toolCallID,
return yield* new ToolFailure({ }
message: "oldString must not be empty. Use write to create or overwrite a file.", if (input.oldString === input.newString) {
return yield* new ToolFailure({
message: "No changes to apply: oldString and newString are identical.",
})
}
if (input.oldString === "") {
return yield* new ToolFailure({
message: "oldString must not be empty. Use write to create or overwrite a file.",
})
}
const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" }))
const external = target.externalDirectory
if (external) {
yield* unableToEdit(
permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
}),
)
}
yield* unableToEdit(
permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
}),
)
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
const ending = detectLineEnding(source.text)
const oldString = convertToLineEnding(input.oldString, ending)
const newString = convertToLineEnding(input.newString, ending)
const replacements = countOccurrences(source.text, oldString)
if (replacements === 0) {
return yield* new ToolFailure({
message:
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
})
}
if (replacements > 1 && input.replaceAll !== true) {
return yield* new ToolFailure({
message:
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
})
}
const replaced =
input.replaceAll === true
? source.text.replaceAll(oldString, newString)
: source.text.replace(oldString, newString)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
target,
expected: source.content,
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Output
}) })
} },
}),
const target = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" })) "edit",
const external = target.externalDirectory ),
if (external) { })
yield* unableToEdit(assertPermission(LocationMutation.externalDirectoryPermission(external))) .pipe(Effect.orDie)
}
yield* unableToEdit(assertPermission({ action: "edit", resources: [target.resource], save: ["*"] }))
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
const ending = detectLineEnding(source.text)
const oldString = convertToLineEnding(parameters.oldString, ending)
const newString = convertToLineEnding(parameters.newString, ending)
const replacements = countOccurrences(source.text, oldString)
if (replacements === 0) {
return yield* new ToolFailure({
message:
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
})
}
if (replacements > 1 && parameters.replaceAll !== true) {
return yield* new ToolFailure({
message:
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
})
}
const replaced =
parameters.replaceAll === true
? source.text.replaceAll(oldString, newString)
: source.text.replace(oldString, newString)
const next = splitBom(replaced)
const result = yield* unableToEdit(
files.writeIfUnchanged({
target,
expected: source.content,
content: joinBom(next.text, source.bom || next.bom),
}),
)
return { ...result, replacements } satisfies Success
})
},
}),
)
}), }),
) )

View file

@ -1,14 +1,16 @@
export * as GlobTool from "./glob" export * as GlobTool from "./glob"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search" import { LocationSearch } from "../location-search"
import { ToolRegistry } from "./registry" import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "glob" export const name = "glob"
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }), pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: LocationSearch.FilesInput.fields.path.annotate({ path: LocationSearch.FilesInput.fields.path.annotate({
description: "Relative directory to search. Defaults to the active Location.", description: "Relative directory to search. Defaults to the active Location.",
@ -36,14 +38,6 @@ export const toModelOutput = (output: ModelOutput) => {
return lines.join("\n") return lines.join("\n")
} }
const definition = Tool.make({
description:
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
parameters: Parameters,
success: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
/** /**
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata; * Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and traversal. * LocationSearch resolves the current root and owns containment and traversal.
@ -52,39 +46,42 @@ const definition = Tool.make({
*/ */
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.make({
execute: ({ parameters, assertPermission }) => description:
Effect.gen(function* () { "Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
const root = yield* filesystem.resolveRoot({ path: parameters.path, reference: parameters.reference }) input: Input,
yield* assertPermission({ output: LocationSearch.FilesResult,
action: name, toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
resources: [parameters.pattern], execute: (input, context) =>
save: ["*"], Effect.gen(function* () {
metadata: { const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference })
root: root.resource, yield* permission.assert({
reference: parameters.reference, action: name,
path: parameters.path, resources: [input.pattern],
limit: parameters.limit, save: ["*"],
}, metadata: {
}) root: root.resource,
return yield* search.files(parameters) reference: input.reference,
}).pipe( path: input.path,
Effect.catchCause((cause) => limit: input.limit,
Effect.fail( },
new ToolFailure({ sessionID: context.sessionID,
message: `Unable to find files matching ${parameters.pattern}`, agent: context.agent,
error: Cause.squash(cause), source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
}), })
), return yield* search.files(input)
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
), ),
), }),
}), })
) .pipe(Effect.orDie)
}), }),
) )

View file

@ -1,15 +1,17 @@
export * as GrepTool from "./grep" export * as GrepTool from "./grep"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search" import { LocationSearch } from "../location-search"
import { Ripgrep } from "../ripgrep" import { Ripgrep } from "../ripgrep"
import { ToolRegistry } from "./registry" import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "grep" export const name = "grep"
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
pattern: LocationSearch.GrepInput.fields.pattern.annotate({ pattern: LocationSearch.GrepInput.fields.pattern.annotate({
description: "Regex pattern to search for in file contents", description: "Regex pattern to search for in file contents",
}), }),
@ -27,10 +29,10 @@ export const Parameters = Schema.Struct({
}), }),
}) })
type Success = typeof LocationSearch.GrepResult.Encoded type Output = typeof LocationSearch.GrepResult.Encoded
/** Format raw Location search matches into the familiar concise model output. */ /** Format raw Location search matches into the familiar concise model output. */
export const toModelOutput = (output: Success) => { export const toModelOutput = (output: Output) => {
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`] const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
let current = "" let current = ""
for (const match of output.items) { for (const match of output.items) {
@ -51,14 +53,6 @@ export const toModelOutput = (output: Success) => {
return lines.join("\n") return lines.join("\n")
} }
const definition = Tool.make({
description:
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
parameters: Parameters,
success: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
/** /**
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata; * Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and ripgrep execution. * LocationSearch resolves the current root and owns containment and ripgrep execution.
@ -67,40 +61,49 @@ const definition = Tool.make({
*/ */
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.make({
execute: ({ parameters, assertPermission }) => description:
Effect.gen(function* () { "Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
const root = yield* filesystem.resolveRoot(parameters) input: Input,
yield* assertPermission({ output: LocationSearch.GrepResult,
action: name, toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
resources: [parameters.pattern], execute: (input, context) =>
save: ["*"], Effect.gen(function* () {
metadata: { const root = yield* filesystem.resolveRoot(input)
root: root.resource, yield* permission.assert({
reference: parameters.reference, action: name,
path: parameters.path, resources: [input.pattern],
include: parameters.include, save: ["*"],
limit: parameters.limit, metadata: {
}, root: root.resource,
}) reference: input.reference,
return yield* search.grep(parameters) path: input.path,
}).pipe( include: input.include,
Effect.catchCause((cause) => { limit: input.limit,
const error = Cause.squash(cause) },
const message = sessionID: context.sessionID,
error instanceof Ripgrep.InvalidPatternError agent: context.agent,
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}` source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
: `Unable to grep for ${parameters.pattern}` })
return Effect.fail(new ToolFailure({ message, error })) return yield* search.grep(input)
}), }).pipe(
), Effect.mapError((error) => {
}), const message =
) error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(input.pattern)}: ${error.message}`
: `Unable to grep for ${input.pattern}`
return new ToolFailure({ message })
}),
),
}),
})
.pipe(Effect.orDie)
}), }),
) )

View file

@ -1,73 +0,0 @@
export * as NativeTool from "./native"
import { Tool, ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import type { SessionSchema } from "../session/schema"
export interface Context {
readonly sessionID: SessionSchema.ID
readonly id: string
readonly name: string
}
export type SchemaType<A> = Schema.Codec<A, any, never, never>
export interface Executable<Parameters extends SchemaType<any>, Success extends SchemaType<any>> {
readonly definition: Tool.Tool<Parameters, Success>
readonly execute: (
parameters: Schema.Schema.Type<Parameters>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
}
export type Any = Executable<any, any>
export const Failure = ToolFailure
export type Failure = ToolFailure
export type Content =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly data: string
readonly mime: string
readonly name?: string
}
export function make<Parameters extends SchemaType<any>, Success extends SchemaType<any>>(config: {
readonly description: string
readonly parameters: Parameters
readonly success: Success
readonly execute: (
parameters: Schema.Schema.Type<Parameters>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly toModelOutput?: (input: {
readonly callID: string
readonly parameters: Schema.Schema.Type<Parameters>
readonly output: Success["Encoded"]
}) => ReadonlyArray<Content>
}): Executable<Parameters, Success> {
const toModelOutput = config.toModelOutput
return {
definition: Tool.make({
description: config.description,
parameters: config.parameters,
success: config.success,
toModelOutput: toModelOutput
? (input) =>
toModelOutput(input).map((content) =>
content.type === "text"
? content
: {
type: "file",
source: { type: "data", data: content.data },
mime: content.mime,
name: content.name,
},
)
: undefined,
}),
execute: config.execute,
}
}

View file

@ -1,9 +1,11 @@
export * as QuestionTool from "./question" export * as QuestionTool from "./question"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question" import { QuestionV2 } from "../question"
import { ToolRegistry } from "./registry" import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "question" export const name = "question"
@ -18,14 +20,14 @@ Usage notes:
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one - Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label` - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }), questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
}) })
export const Success = Schema.Struct({ export const Output = Schema.Struct({
answers: Schema.Array(QuestionV2.Answer), answers: Schema.Array(QuestionV2.Answer),
}) })
export type Success = typeof Success.Type export type Output = typeof Output.Type
export const toModelOutput = ( export const toModelOutput = (
questions: ReadonlyArray<QuestionV2.Prompt>, questions: ReadonlyArray<QuestionV2.Prompt>,
@ -40,42 +42,45 @@ export const toModelOutput = (
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.` return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
} }
const definition = Tool.make({
description,
parameters: Parameters,
success: Success,
toModelOutput: ({ parameters, output }) => [
toolText({ type: "text", text: toModelOutput(parameters.questions, output.answers) }),
],
})
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const question = yield* QuestionV2.Service const question = yield* QuestionV2.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.make({
permission: { action: "question", resource: "*" }, description,
authorize: ({ assertPermission }) => input: Input,
assertPermission({ action: "question", resources: ["*"] }).pipe( output: Output,
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), toModelOutput: ({ input, output }) => [
), toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }),
execute: ({ parameters, sessionID, source }) => ],
question execute: (input, context) =>
.ask({ permission
sessionID, .assert({
questions: parameters.questions, action: "question",
// The registry intentionally leaves source absent until it owns the durable assistant message ID. resources: ["*"],
tool: source?.type === "tool" ? { messageID: source.messageID, callID: source.callID } : undefined, sessionID: context.sessionID,
}) agent: context.agent,
.pipe( source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
Effect.map((answers) => ({ answers })), })
// V1 treats a dismissed question as an interrupted tool invocation rather than model-facing text. .pipe(
Effect.orDie, Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
), Effect.andThen(
}), question
) .ask({
sessionID: context.sessionID,
questions: input.questions,
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
})
.pipe(Effect.orDie),
),
Effect.map((answers) => ({ answers })),
),
}),
})
.pipe(Effect.orDie)
}), }),
) )

View file

@ -1,46 +1,15 @@
export * as ReadTool from "./read" export * as ReadTool from "./read"
import { Tool, ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm. import { Effect, Layer, Schema } from "effect"
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Cause, Effect, Layer, Schema } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { Config } from "../config"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { Image } from "../image"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry" import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "read" export const name = "read"
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024
const MAX_IMAGE_WIDTH = 2_000
const MAX_IMAGE_HEIGHT = 2_000
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
class ImageDecodeError extends Error {
constructor(readonly resource: string) {
super(`Image could not be decoded: ${resource}`)
this.name = "ImageDecodeError"
}
}
class ImageSizeError extends Error {
constructor(
readonly resource: string,
readonly width: number,
readonly height: number,
readonly bytes: number,
readonly maxWidth: number,
readonly maxHeight: number,
readonly maxBytes: number,
) {
super(
`Image ${resource} is ${width}x${height} with base64 size ${bytes}, exceeding configured limits ${maxWidth}x${maxHeight}/${maxBytes} bytes`,
)
this.name = "ImageSizeError"
}
}
const LocationInput = Schema.Struct({ const LocationInput = Schema.Struct({
...FileSystem.ReadInput.fields, ...FileSystem.ReadInput.fields,
offset: FileSystem.ListPageInput.fields.offset.annotate({ offset: FileSystem.ListPageInput.fields.offset.annotate({
@ -51,179 +20,68 @@ const LocationInput = Schema.Struct({
}), }),
}) })
const Input = LocationInput const Input = LocationInput
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage]) const Output = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
const definition = Tool.make({
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
parameters: Input,
success: Success,
toStructuredOutput: (output) =>
"type" in output && output.type === "binary" && SUPPORTED_IMAGE_MIMES.has(output.mime)
? { type: "media", mime: output.mime }
: output,
toModelOutput: ({ parameters, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
return [
{ type: "text", text: "Image read successfully" },
{
type: "file",
source: { type: "data", data: output.content },
mime: output.mime,
name: parameters.path,
},
]
},
})
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service const filesystem = yield* FileSystem.Service
const config = yield* Config.Service const image = yield* Image.Service
const loadPhoton = yield* Effect.cached( const permission = yield* PermissionV2.Service
Effect.sync(() => {
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
)
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.make({
execute: ({ parameters, assertPermission }) => { description:
const input = parameters "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
return Effect.gen(function* () { input: Input,
const resolved = yield* filesystem.resolveReadPath(input) output: Output,
if (resolved.type === "directory") { toModelOutput: ({ input, output }) => {
yield* assertPermission({ action: name, resources: [resolved.resource], save: ["*"] }) if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
return yield* filesystem.listPage(input) return [
} { type: "text", text: "Image read successfully" },
yield* assertPermission({ { type: "file", data: output.content, mime: output.mime, name: input.path },
action: name, ]
resources: [resolved.resource], },
save: ["*"], execute: (input, context) => {
}) return Effect.gen(function* () {
const content = yield* filesystem.readTool(input, { const resolved = yield* filesystem.resolveReadPath(input)
offset: input.offset, yield* permission.assert({
limit: input.limit, action: name,
}) resources: [resolved.resource],
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) { save: ["*"],
const mime = content.mime sessionID: context.sessionID,
const base64 = content.content agent: context.agent,
const image = Object.assign( source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const limits = {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
}
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: () => new ImageDecodeError(resolved.resource),
}) })
try { if (resolved.type === "directory") return yield* filesystem.listPage(input)
const width = decoded.get_width() const content = yield* filesystem.readTool(input, {
const height = decoded.get_height() offset: input.offset,
const bytes = Buffer.byteLength(base64, "utf-8") limit: input.limit,
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes) })
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime }) if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
if (!limits.autoResize) return yield* image
return yield* Effect.die( .normalize(resolved.resource, content)
new ImageSizeError( .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
resolved.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height)
? acc
: [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const candidate = [
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
if (candidate)
return new FileSystem.BinaryContent({
type: "binary",
content: candidate.content,
encoding: "base64",
mime: candidate.mime,
})
} finally {
resized.free()
}
}
return yield* Effect.die(
new ImageSizeError(
resolved.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
} finally {
decoded.free()
} }
} if (content.type === "binary")
if (content.type === "binary") return yield* Effect.die(new FileSystem.BinaryFileError(resolved.resource)) return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
return content return content
}).pipe( }).pipe(
Effect.catchCause((cause) => Effect.mapError((error) => {
Effect.gen(function* () {
const error = Cause.squash(cause)
const message = const message =
error instanceof FileSystem.BinaryFileError || error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.MediaIngestLimitError || error instanceof FileSystem.MediaIngestLimitError ||
error instanceof ImageDecodeError || error instanceof Image.DecodeError ||
error instanceof ImageSizeError error instanceof Image.SizeError
? error.message ? error.message
: `Unable to read ${input.path}` : `Unable to read ${input.path}`
return yield* new ToolFailure({ message, error }) return new ToolFailure({ message })
}), }),
), )
) },
}, }),
}), })
) .pipe(Effect.orDie)
}), }),
) )
export const locationLayer = layer.pipe(
Layer.provideMerge(ToolRegistry.defaultLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(PermissionV2.locationLayer),
)

View file

@ -1,91 +1,33 @@
export * as ToolRegistry from "./registry" export * as ToolRegistry from "./registry"
import { import { ToolOutput, type ToolCall, type ToolDefinition, type ToolSettlement } from "@opencode-ai/llm"
Tool, import { Context, Effect, Layer, Scope } from "effect"
ToolFailure,
ToolOutput,
ToolResultValue as ToolResult,
type Tool as TypedTool,
type ToolCall,
type ToolResultValue,
type ToolSchema,
type ToolSettlement,
} from "@opencode-ai/llm"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet } from "immer"
import { PermissionV2 } from "../permission"
import { State } from "../state"
import { SessionSchema } from "../session/schema"
import type { SessionV2 } from "../session"
import { ApplicationTools } from "./application-tools"
import { ToolOutputStore } from "../tool-output-store"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard" import { Wildcard } from "../util/wildcard"
import { ApplicationTools } from "./application-tools"
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
import { Tools } from "./tools"
export type ExecuteInput = { export type ExecuteInput = {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly agent?: AgentV2.ID readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly call: ToolCall readonly call: ToolCall
} }
/**
* Narrow cross-cutting context for one registry invocation. Leaf tools retain
* ownership of sequence-sensitive policy decisions; the registry only binds
* identity and shared helper behavior consistently.
*
* TODO: Add `source` when the runner can pass the durable owning assistant
* message ID alongside the call ID. Do not infer it from the tool call alone.
* TODO: Add cancellation and progress only when the runner exposes a real
* signal and durable/live progress sink.
*/
export type Invocation = ExecuteInput & {
readonly source?: PermissionV2.Source
readonly assertPermission: (
input: Omit<PermissionV2.AssertInput, "sessionID" | "agent" | "source">,
) => Effect.Effect<void, PermissionV2.Error | SessionV2.NotFoundError>
}
/** Kept as the leaf entry input name for backwards-compatible execute usage. */
export type AuthorizeInput<Parameters = unknown> = Invocation & {
readonly parameters: Parameters
}
export type Entry<
Parameters extends ToolSchema<any> = ToolSchema<any>,
Success extends ToolSchema<any> = ToolSchema<any>,
> = {
readonly tool: TypedTool<Parameters, Success>
/** Catalog visibility only. Execution authorization remains leaf-owned. */
readonly permission?: { readonly action: string; readonly resource: "*" }
readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure>
readonly execute?: (
input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly outputPaths?: (output: Schema.Schema.Type<Success>) => ReadonlyArray<string>
}
type Data = {
readonly entries: Map<string, Entry>
}
export type Editor = {
readonly list: () => ReadonlyArray<readonly [string, Entry]>
readonly get: (name: string) => Entry | undefined
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(
name: string,
entry: Entry<Parameters, Success>,
) => void
readonly remove: (name: string) => void
}
export interface Interface { export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"] readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope> /** Internal registration capability exposed publicly only through Tools.Service. */
readonly definitions: ( readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
permissions?: PermissionV2.Ruleset, }
) => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue> export interface Materialization {
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement> readonly definitions: ReadonlyArray<ToolDefinition>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
} }
export interface Settlement extends ToolSettlement { export interface Settlement extends ToolSettlement {
@ -94,153 +36,98 @@ export interface Settlement extends ToolSettlement {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
enableMapSet() const registryLayer = Layer.effect(
export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const applications = yield* ApplicationTools.Service const applications = yield* ApplicationTools.Service
const resources = yield* ToolOutputStore.Service const resources = yield* ToolOutputStore.Service
const state = State.create<Data, Editor>({ type Registration = { readonly identity: object; readonly tool: AnyTool }
initial: () => ({ entries: new Map() }), const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
editor: (draft) => ({
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
get: (name) => draft.entries.get(name) as Entry | undefined,
set: (name, entry) => {
draft.entries.set(
name,
castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
)
},
remove: (name) => {
draft.entries.delete(name)
},
}),
})
const definitions = Effect.fn("ToolRegistry.definitions")(function* (permissions: PermissionV2.Ruleset = []) { const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
const tools = new Map(state.get().entries) const registration =
// Location tools own their names. Application tools fill otherwise-unclaimed names. local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name)
for (const [name, tool] of applications.entries()) { if (!registration)
if (!tools.has(name)) tools.set(name, { tool: tool.definition }) return {
} result: {
return Tool.toDefinitions( type: "error" as const,
Object.fromEntries( value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
Array.from(tools) },
.filter(([name, entry]) => !whollyDisabled(entry.permission ?? defaultPermission(name), permissions)) }
.map(([name, entry]) => [name, entry.tool]), if (advertised && registration.identity !== advertised)
), return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
) const pending = yield* settle(registration.tool, input.call, {
}) sessionID: input.sessionID,
agent: input.agent,
const entry = (name: string): Entry | undefined => { assistantMessageID: input.assistantMessageID,
const local = state.get().entries.get(name) toolCallID: input.call.id,
if (local !== undefined) return local }).pipe(
const tool = applications.entries().get(name) Effect.map((output) => ({ output })),
if (tool === undefined) return
return {
tool: tool.definition,
execute: ({ parameters, sessionID, call }) =>
tool.execute(parameters, { sessionID, id: call.id, name: call.name }),
}
}
const invocation = (input: ExecuteInput): Invocation => ({
...input,
// Source needs the durable owning assistant message ID, which the registry does not receive yet.
assertPermission: (request) =>
permission.assert({ ...request, sessionID: input.sessionID, ...(input.agent ? { agent: input.agent } : {}) }),
})
const settleEntry = Effect.fn("ToolRegistry.settleEntry")(function* (
entry: Entry | undefined,
input: ExecuteInput,
) {
if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
if (!entry.execute && !entry.tool.execute)
return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
return yield* entry.tool._decode(input.call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((parameters) => {
const context = { ...invocation(input), parameters }
const execute =
entry.execute?.(context) ?? entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name })
return (
entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))
).pipe(
Effect.flatMap((value) =>
entry.tool._encode(value).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its success schema: ${error.message}`,
}),
),
),
),
Effect.map((value): Settlement => {
const settled = (() => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
})()
const retained = entry.outputPaths?.(value) ?? []
return retained.length > 0 ? { ...settled, outputPaths: retained } : settled
}),
)
}),
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }), Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
), ),
) )
}) if ("result" in pending) return pending
const output = pending.output
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) => const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
Effect.uninterruptibleMask((restore) => const result = ToolOutput.toResultValue(bounded.output)
Effect.gen(function* () { if (result.type === "error")
const settled = yield* restore(settleEntry(entry(input.call.name), input)) return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
if (!settled.output) return settled return bounded.outputPaths.length > 0
const bounded = yield* resources.bound({ ? { result, output: bounded.output, outputPaths: bounded.outputPaths }
sessionID: input.sessionID, : { result, output: bounded.output }
toolCallID: input.call.id,
output: settled.output,
})
if (bounded.output === settled.output && bounded.outputPaths.length === 0) return settled
const retained = [...(settled.outputPaths ?? []), ...bounded.outputPaths]
const result = ToolOutput.toResultValue(bounded.output)
return result.type === "error"
? { result, outputPaths: retained }
: { result, output: bounded.output, outputPaths: retained }
}),
),
)
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result
}) })
return Service.of({ return Service.of({
transform: state.transform, register: Effect.fn("ToolRegistry.register")(function* (tools) {
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) { const entries = Object.entries(tools)
const transform = yield* state.transform() if (entries.length === 0) return
yield* transform(update) yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true })
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const [name, tool] of entries)
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(name, registrations)
else local.delete(name)
}
}),
)
}),
)
}),
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = []) {
const registrations = new Map(applications.entries())
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (registration) registrations.set(name, registration)
}
for (const [name, registration] of registrations)
if (whollyDisabled(permission(registration.tool, name), permissions)) registrations.delete(name)
return {
definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
settle: (input) => {
const registration = registrations.get(input.call.name)
if (registration) return settleWith(input, registration.identity)
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
},
}
}), }),
definitions,
execute,
settle,
}) })
}), }),
) )
function defaultPermission(name: string) { export const layer = Layer.effect(
return { action: ["edit", "write", "apply_patch"].includes(name) ? "edit" : name, resource: "*" as const } Tools.Service,
} Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
).pipe(Layer.provideMerge(registryLayer))
function whollyDisabled(permission: { readonly action: string; readonly resource: "*" }, rules: PermissionV2.Ruleset) { function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(permission.action, rule.action)) const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny" return rule?.resource === "*" && rule.effect === "deny"
} }

View file

@ -2,27 +2,26 @@ export * as SkillTool from "./skill"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { PluginBoot } from "../plugin/boot" import { PluginBoot } from "../plugin/boot"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { ToolOutputStore } from "../tool-output-store" import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry" import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "skill" export const name = "skill"
const FILE_LIMIT = 10 const FILE_LIMIT = 10
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }), name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
}) })
export const Success = Schema.Struct({ export const Output = Schema.Struct({
name: Schema.String, name: Schema.String,
directory: Schema.String, directory: Schema.String,
output: Schema.String, output: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
}) })
export const description = [ export const description = [
@ -57,53 +56,50 @@ const unableToLoad = (name: string, error?: unknown) =>
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const boot = yield* PluginBoot.Service const boot = yield* PluginBoot.Service
const skills = yield* SkillV2.Service const skills = yield* SkillV2.Service
const resources = yield* ToolOutputStore.Service const permission = yield* PermissionV2.Service
yield* boot.wait() yield* boot.wait()
const definition = Tool.make({ yield* tools
description, .register({
parameters: Parameters, [name]: Tool.make({
success: Success, description,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })], input: Input,
}) output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
yield* registry.contribute((editor) => execute: (input, context) =>
editor.set(name, { Effect.gen(function* () {
tool: definition, const current = yield* skills.list()
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []), const skill = current.find((skill) => skill.name === input.name)
execute: ({ parameters, sessionID, call, assertPermission }) => if (!skill) return yield* unableToLoad(input.name)
Effect.gen(function* () { return yield* Effect.gen(function* () {
const current = yield* skills.list() yield* permission.assert({
const skill = current.find((skill) => skill.name === parameters.name) action: name,
if (!skill) return yield* unableToLoad(parameters.name) resources: [skill.name],
return yield* Effect.gen(function* () { save: [skill.name],
yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] }) sessionID: context.sessionID,
const directory = path.dirname(skill.location) agent: context.agent,
const files = source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
path.basename(skill.location) === "SKILL.md" })
? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) const directory = path.dirname(skill.location)
.filter((file) => path.basename(file) !== "SKILL.md") const files =
.toSorted() path.basename(skill.location) === "SKILL.md"
.slice(0, FILE_LIMIT) ? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
: [] .filter((file) => path.basename(file) !== "SKILL.md")
const output = yield* resources.truncate({ .toSorted()
sessionID, .slice(0, FILE_LIMIT)
toolCallID: call.id, : []
content: toModelOutput(skill, files), return {
}) name: skill.name,
return { directory,
name: skill.name, output: toModelOutput(skill, files),
directory, }
output: output.content, }).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
truncated: output.truncated, }),
...(output.truncated ? { outputPath: output.outputPath } : {}), }),
} })
}).pipe(Effect.catchCause((cause) => Effect.fail(unableToLoad(parameters.name, Cause.squash(cause))))) .pipe(Effect.orDie)
}),
}),
)
}), }),
) )

View file

@ -1,50 +1,54 @@
export * as TodoWriteTool from "./todowrite" export * as TodoWriteTool from "./todowrite"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { SessionTodo } from "../session/todo" import { SessionTodo } from "../session/todo"
import { ToolRegistry } from "./registry" import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "todowrite" export const name = "todowrite"
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }), todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }),
}) })
export const Success = Schema.Struct({ export const Output = Schema.Struct({
todos: Schema.Array(SessionTodo.Info), todos: Schema.Array(SessionTodo.Info),
}) })
export type Success = typeof Success.Type export type Output = typeof Output.Type
export const toModelOutput = (output: Success) => JSON.stringify(output.todos, null, 2) export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
const definition = Tool.make({
description:
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const todos = yield* SessionTodo.Service const todos = yield* SessionTodo.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.make({
execute: ({ parameters, sessionID, assertPermission }) => description:
Effect.gen(function* () { "Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
yield* assertPermission({ action: name, resources: ["*"], save: ["*"] }) input: Input,
yield* todos.update({ sessionID, todos: parameters.todos }) output: Output,
return { todos: parameters.todos } toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
}).pipe( execute: (input, context) =>
Effect.catchCause((cause) => Effect.gen(function* () {
Effect.fail(new ToolFailure({ message: "Unable to update todos", error: Cause.squash(cause) })), yield* permission.assert({
), action: name,
), resources: ["*"],
}), save: ["*"],
) sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
return { todos: input.todos }
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
}),
})
.pipe(Effect.orDie)
}), }),
) )

View file

@ -0,0 +1,145 @@
export * as Tool from "./tool"
import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
import { Effect, JsonSchema, Schema } from "effect"
import type { AgentV2 } from "../agent"
import type { SessionMessage } from "../session/message"
import type { SessionSchema } from "../session/schema"
export interface Context {
readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
}
export type SchemaType<A> = Schema.Codec<A, any, never, never>
declare const TypeId: unique symbol
export interface Definition<Input extends SchemaType<any>, Output extends SchemaType<any>> {
readonly [TypeId]: {
readonly _Input: Input
readonly _Output: Output
}
}
export type AnyTool = Definition<any, any>
export const Failure = ToolFailure
export type Failure = ToolFailure
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
name: Schema.String,
message: Schema.String,
}) {}
export type Content =
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
readonly description: string
readonly input: Input
readonly output: Output
readonly execute: (
input: Schema.Schema.Type<Input>,
context: Context,
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure>
readonly toModelOutput?: (input: {
readonly input: Schema.Schema.Type<Input>
readonly output: Output["Encoded"]
}) => ReadonlyArray<Content>
}
type Runtime = {
readonly permission?: string
readonly definition: (name: string) => ToolDefinition
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
}
const runtimes = new WeakMap<AnyTool, Runtime>()
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
config: Config<Input, Output>,
): Definition<Input, Output> {
const tool = Object.freeze({}) as Definition<Input, Output>
const definitions = new Map<string, ToolDefinition>()
runtimes.set(tool, {
definition: (name) => {
const cached = definitions.get(name)
if (cached) return cached
const definition = new ToolDefinition({
name,
description: config.description,
inputSchema: toJsonSchema(config.input),
outputSchema: toJsonSchema(config.output),
})
definitions.set(name, definition)
return definition
},
settle: (call, context) =>
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
Effect.flatMap((input) =>
config.execute(input, context).pipe(
Effect.flatMap((output) =>
Schema.encodeEffect(config.output)(output).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `Tool returned an invalid value for its output schema: ${error.message}`,
}),
),
),
),
Effect.map((output) =>
ToolOutput.make(
output,
config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
source: { type: "data" as const, data: part.data },
mime: part.mime,
name: part.name,
},
) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
),
),
),
),
),
})
return tool
}
export const validateName = (name: string) =>
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
tool: Definition<Input, Output>,
permission: string,
) => {
const decorated = Object.freeze({}) as Definition<Input, Output>
runtimes.set(decorated, { ...runtimeOf(tool), permission })
return decorated
}
export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name
export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name)
export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context)
function runtimeOf(tool: AnyTool) {
const runtime = runtimes.get(tool)
if (!runtime) throw new TypeError("Invalid Core Tool value")
return runtime
}
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
return { ...document.schema, $defs: document.definitions }
}

View file

@ -0,0 +1,13 @@
export * as Tools from "./tools"
import { Context, Effect, Scope } from "effect"
import { Tool } from "./tool"
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
/** Narrow registration-only Location capability. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Tools") {}

View file

@ -1,12 +1,13 @@
export * as WebFetchTool from "./webfetch" export * as WebFetchTool from "./webfetch"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Duration, Effect, Layer, Schema, Stream } from "effect" import { Duration, Effect, Layer, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2" import { Parser } from "htmlparser2"
import TurndownService from "turndown" import TurndownService from "turndown"
import { ToolOutputStore } from "../tool-output-store" import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry" import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "webfetch" export const name = "webfetch"
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024 export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
@ -15,11 +16,11 @@ export const MAX_TIMEOUT_SECONDS = 120
export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default. export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated and saved to a managed file that ordinary Read, Grep, and Bash tools can inspect.` Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.`
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS)) const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }), url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }),
format: Schema.Literals(["text", "markdown", "html"]) format: Schema.Literals(["text", "markdown", "html"])
.annotate({ description: "The format to return the content in. Defaults to markdown." }) .annotate({ description: "The format to return the content in. Defaults to markdown." })
@ -29,16 +30,14 @@ export const Parameters = Schema.Struct({
}), }),
}) })
const Success = Schema.Struct({ const Output = Schema.Struct({
url: Schema.String, url: Schema.String,
contentType: Schema.String, contentType: Schema.String,
format: Parameters.fields.format, format: Input.fields.format,
output: Schema.String, output: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
}) })
type Format = (typeof Parameters.Type)["format"] type Format = (typeof Input.Type)["format"]
const acceptHeader = (format: Format) => { const acceptHeader = (format: Format) => {
switch (format) { switch (format) {
@ -49,6 +48,7 @@ const acceptHeader = (format: Format) => {
case "html": case "html":
return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
} }
return "*/*"
} }
const headers = (format: Format, userAgent: string) => ({ const headers = (format: Format, userAgent: string) => ({
@ -89,15 +89,17 @@ const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () { Effect.gen(function* () {
const contentLength = response.headers["content-length"] const contentLength = response.headers["content-length"]
if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) { if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
return yield* Effect.die(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)) return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
} }
const chunks: Uint8Array[] = [] const chunks: Uint8Array[] = []
let size = 0 let size = 0
yield* Stream.runForEach(response.stream, (chunk) => yield* Stream.runForEach(response.stream, (chunk) =>
Effect.sync(() => { Effect.gen(function* () {
size += chunk.byteLength size += chunk.byteLength
if (size > MAX_RESPONSE_BYTES) throw new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`) if (size > MAX_RESPONSE_BYTES)
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
chunks.push(chunk) chunks.push(chunk)
return undefined
}), }),
) )
return Buffer.concat(chunks, size) return Buffer.concat(chunks, size)
@ -115,9 +117,6 @@ const isTextualMime = (mime: string) =>
mime.endsWith("+xml") || mime.endsWith("+xml") ||
mime === "application/javascript" || mime === "application/javascript" ||
mime === "application/x-javascript" mime === "application/x-javascript"
const outputMime = (format: Format) =>
format === "markdown" ? "text/markdown" : format === "html" ? "text/html" : "text/plain"
const convert = (content: string, contentType: string, format: Format) => { const convert = (content: string, contentType: string, format: Format) => {
if (!contentType.includes("text/html")) return content if (!contentType.includes("text/html")) return content
if (format === "markdown") return convertHTMLToMarkdown(content) if (format === "markdown") return convertHTMLToMarkdown(content)
@ -125,71 +124,64 @@ const convert = (content: string, contentType: string, format: Format) => {
return content return content
} }
const definition = Tool.make({
description,
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
})
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const resources = yield* ToolOutputStore.Service const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.make({
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []), description,
execute: ({ parameters, sessionID, call, assertPermission }) => input: Input,
Effect.gen(function* () { output: Output,
const parsed = new URL(parameters.url) toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
assertHttpUrl(parsed) execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({
try: () => assertHttpUrl(new URL(input.url)),
catch: (error) => error,
})
yield* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters }) yield* permission.assert({
action: name,
resources: [input.url],
save: ["*"],
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const { body, contentType } = yield* Effect.gen(function* () { const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, parameters.url, parameters.format).pipe( const response = yield* execute(http, input.url, input.format).pipe(
Effect.catchIf(isCloudflareChallenge, () => Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
execute(http, parameters.url, parameters.format, "opencode"), )
), const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)
if (isImageAttachment(mime))
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
if (!isTextualMime(mime))
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
return { body: yield* collectBody(response), contentType }
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
orElse: () => Effect.fail(new Error("Request timed out")),
}),
) )
const contentType = response.headers["content-type"] || "" const content = convert(new TextDecoder().decode(body), contentType, input.format)
const mime = mimeFrom(contentType) return {
if (isImageAttachment(mime)) throw new Error(`Unsupported fetched image content type: ${mime}`) url: input.url,
if (!isTextualMime(mime)) throw new Error(`Unsupported fetched file content type: ${mime}`) contentType,
return { body: yield* collectBody(response), contentType } format: input.format,
}).pipe( output: content,
Effect.timeoutOrElse({ }
duration: Duration.seconds(parameters.timeout ?? DEFAULT_TIMEOUT_SECONDS), }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
orElse: () => Effect.die(new Error("Request timed out")), }),
}), })
) .pipe(Effect.orDie)
const content = convert(new TextDecoder().decode(body), contentType, parameters.format)
const truncated = yield* resources.truncate({
sessionID,
toolCallID: call.id,
content,
mime: outputMime(parameters.format),
})
return {
url: parameters.url,
contentType,
format: parameters.format,
output: truncated.content,
truncated: truncated.truncated,
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
}
}).pipe(
Effect.catchCause((cause) =>
Effect.fail(
new ToolFailure({ message: `Unable to fetch ${parameters.url}`, error: Cause.squash(cause) }),
),
),
),
}),
)
}), }),
) )

View file

@ -1,13 +1,14 @@
export * as WebSearchTool from "./websearch" export * as WebSearchTool from "./websearch"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Context, Duration, Effect, Layer, Schema } from "effect" import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { truthy } from "../flag/flag" import { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version" import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema" import { PositiveInt } from "../schema"
import { ToolOutputStore } from "../tool-output-store" import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry" import { Tool } from "./tool"
import { Tools } from "./tools"
import { checksum } from "../util/encode" import { checksum } from "../util/encode"
export const name = "websearch" export const name = "websearch"
@ -32,7 +33,7 @@ Optional controls support result count, live crawling ('fallback' or 'preferred'
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.` The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
query: Schema.String.annotate({ description: "Websearch query" }), query: Schema.String.annotate({ description: "Websearch query" }),
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({ numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`, description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
@ -165,95 +166,81 @@ const callMcp = <F extends Schema.Struct.Fields>(
const response = yield* HttpClient.filterStatusOk(http).execute(request) const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* response.text const body = yield* response.text
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES) if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES)
return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`)) return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
return yield* parseResponse(body) return yield* parseResponse(body)
}).pipe( }).pipe(
Effect.timeoutOrElse({ Effect.timeoutOrElse({
duration: Duration.seconds(25), duration: Duration.seconds(25),
orElse: () => Effect.die(new Error(`${tool} request timed out`)), orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
}), }),
) )
}) })
const Success = Schema.Struct({ const Output = Schema.Struct({
provider: Provider, provider: Provider,
text: Schema.String, text: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
})
const definition = Tool.make({
description,
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
}) })
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const config = yield* ConfigService const config = yield* ConfigService
const resources = yield* ToolOutputStore.Service const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.make({
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []), description,
execute: ({ parameters, sessionID, call, assertPermission }) => { input: Input,
const provider = selectProvider(sessionID, config, config.provider) output: Output,
return Effect.gen(function* () { toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
yield* assertPermission({ execute: (input, context) => {
action: name, const provider = selectProvider(context.sessionID, config, config.provider)
resources: [parameters.query], return Effect.gen(function* () {
save: ["*"], yield* permission.assert({
metadata: { ...parameters, provider }, action: name,
}) resources: [input.query],
save: ["*"],
metadata: { ...input, provider },
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const text = const text =
provider === "exa" provider === "exa"
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, { ? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
query: parameters.query, query: input.query,
type: parameters.type || "auto", type: input.type || "auto",
numResults: parameters.numResults || 8, numResults: input.numResults || 8,
livecrawl: parameters.livecrawl || "fallback", livecrawl: input.livecrawl || "fallback",
contextMaxCharacters: parameters.contextMaxCharacters, contextMaxCharacters: input.contextMaxCharacters,
}) })
: yield* callMcp( : yield* callMcp(
http, http,
PARALLEL_URL, PARALLEL_URL,
"web_search", "web_search",
ParallelArgs, ParallelArgs,
{ {
objective: parameters.query, objective: input.query,
search_queries: [parameters.query], search_queries: [input.query],
session_id: sessionID, session_id: context.sessionID,
// V2 invocation context does not safely expose the model yet. // V2 invocation context does not safely expose the model yet.
}, },
{ {
"User-Agent": `opencode/${InstallationVersion}`, "User-Agent": `opencode/${InstallationVersion}`,
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
}, },
) )
const truncated = yield* resources.truncate({ sessionID, toolCallID: call.id, content: text ?? NO_RESULTS }) return {
return { provider,
provider, text: text ?? NO_RESULTS,
text: truncated.content, }
truncated: truncated.truncated, }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}), },
} }),
}).pipe( })
Effect.catchCause((cause) => .pipe(Effect.orDie)
Effect.fail(
new ToolFailure({
message: `Unable to search the web for ${parameters.query}`,
error: Cause.squash(cause),
}),
),
),
)
},
}),
)
}), }),
) )

View file

@ -7,16 +7,18 @@
*/ */
export * as WriteTool from "./write" export * as WriteTool from "./write"
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm" import { ToolFailure, toolText } from "@opencode-ai/llm"
import { Cause, Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { ToolRegistry } from "./registry" import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "write" export const name = "write"
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior. // TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
export const Parameters = Schema.Struct({ export const Input = Schema.Struct({
path: Schema.String.annotate({ path: Schema.String.annotate({
description: description:
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.", "File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
@ -24,25 +26,17 @@ export const Parameters = Schema.Struct({
content: Schema.String.annotate({ description: "Content to write to the file" }), content: Schema.String.annotate({ description: "Content to write to the file" }),
}) })
export const Success = Schema.Struct({ export const Output = Schema.Struct({
operation: Schema.Literal("write"), operation: Schema.Literal("write"),
target: Schema.String, target: Schema.String,
resource: Schema.String, resource: Schema.String,
existed: Schema.Boolean, existed: Schema.Boolean,
}) })
export type Success = typeof Success.Type export type Output = typeof Output.Type
export const toModelOutput = (output: Success) => export const toModelOutput = (output: Output) =>
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}` `${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
const definition = Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
parameters: Parameters,
success: Success,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
})
/** Deferred V2 write UX integrations remain visible at the model-facing seam. */ /** Deferred V2 write UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after V2 formatter runtime exists. // TODO: Add formatter integration after V2 formatter runtime exists.
// TODO: Publish watcher/file-edit events after V2 watcher integration exists. // TODO: Publish watcher/file-edit events after V2 watcher integration exists.
@ -51,28 +45,50 @@ const definition = Tool.make({
export const layer = Layer.effectDiscard( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
const permission = yield* PermissionV2.Service
yield* registry.contribute((editor) => yield* tools
editor.set(name, { .register({
tool: definition, [name]: Tool.withPermission(
execute: ({ parameters, assertPermission }) => Tool.make({
Effect.gen(function* () { description:
const target = yield* mutation.resolve({ path: parameters.path, kind: "file" }) "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
const external = target.externalDirectory input: Input,
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external)) output: Output,
yield* assertPermission({ action: "edit", resources: [target.resource], save: ["*"] }) toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
return yield* files.writeTextPreservingBom({ target, content: parameters.content }) execute: (input, context) =>
}).pipe( Effect.gen(function* () {
Effect.catchCause((cause) => const source = {
Effect.fail( type: "tool" as const,
new ToolFailure({ message: `Unable to write ${parameters.path}`, error: Cause.squash(cause) }), messageID: context.assistantMessageID,
), callID: context.toolCallID,
), }
), const target = yield* mutation.resolve({ path: input.path, kind: "file" })
}), const external = target.externalDirectory
) if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
}),
"edit",
),
})
.pipe(Effect.orDie)
}), }),
) )

View file

@ -58,6 +58,9 @@ let logpath = ""
export function file() { export function file() {
return logpath return logpath
} }
export function getLevel(): Level {
return level
}
let write = (msg: any) => { let write = (msg: any) => {
process.stderr.write(msg) process.stderr.write(msg)
return msg.length return msg.length

View file

@ -3,9 +3,13 @@ import { Tool } from "@opencode-ai/core/public"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Effect, Exit, Layer, Schema, Scope } from "effect" import { Tools } from "@opencode-ai/core/tool/tools"
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const permission = Layer.mock(PermissionV2.Service, { const permission = Layer.mock(PermissionV2.Service, {
@ -20,11 +24,13 @@ const registry = ToolRegistry.layer.pipe(
const it = testEffect(Layer.mergeAll(applications, registry)) const it = testEffect(Layer.mergeAll(applications, registry))
const sessionID = SessionV2.ID.make("ses_application_tool") const sessionID = SessionV2.ID.make("ses_application_tool")
const agent = AgentV2.ID.make("build")
const assistantMessageID = SessionMessage.ID.make("msg_application_tool")
const contextual = (contexts: Tool.Context[]) => const contextual = (contexts: Tool.Context[]) =>
Tool.make({ Tool.make({
description: "Read application context", description: "Read application context",
parameters: Schema.Struct({ query: Schema.String }), input: Schema.Struct({ query: Schema.String }),
success: Schema.Struct({ answer: Schema.String }), output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) => execute: ({ query }, context) =>
Effect.sync(() => { Effect.sync(() => {
contexts.push(context) contexts.push(context)
@ -37,23 +43,69 @@ const contextual = (contexts: Tool.Context[]) =>
}) })
describe("ApplicationTools", () => { describe("ApplicationTools", () => {
it.effect("keeps the Core carrier opaque and executes its single handler", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
const tool = contextual(contexts)
expect(Object.keys(tool)).toEqual([])
yield* applications.register({ opaque: tool })
expect(
yield* executeTool(registry, {
sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "ONCE" },
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
],
})
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
}),
)
it.effect("exposes narrow scoped Location registration and validates names", () =>
Effect.gen(function* () {
const tools: Tools.Interface = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* tools.register({ location_tool: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool"])
expect(yield* Effect.flip(tools.register({ "invalid name": contextual([]) }))).toBeInstanceOf(
Tool.RegistrationError,
)
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])
}),
)
it.effect("filters an application tool by its name without adding execution authorization", () => it.effect("filters an application tool by its name without adding execution authorization", () =>
Effect.gen(function* () { Effect.gen(function* () {
const applications = yield* ApplicationTools.Service const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = [] const contexts: Tool.Context[] = []
yield* applications.attach({ application_context: contextual(contexts) }) yield* applications.register({ application_context: contextual(contexts) })
expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual(
[],
)
expect( expect(
yield* registry.settle({ yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]),
).toEqual([])
expect(
yield* settleTool(registry, {
sessionID, sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } }, call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
}), }),
).toMatchObject({ result: { type: "content" } }) ).toMatchObject({ result: { type: "content" } })
expect(contexts).toEqual([{ sessionID, id: "call-denied", name: "application_context" }]) expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
}), }),
) )
@ -63,14 +115,16 @@ describe("ApplicationTools", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const contexts: Tool.Context[] = [] const contexts: Tool.Context[] = []
yield* applications.attach({ application_context: contextual(contexts) }) yield* applications.register({ application_context: contextual(contexts) })
expect(yield* registry.definitions()).toMatchObject([ expect(yield* toolDefinitions(registry)).toMatchObject([
{ name: "application_context", description: "Read application context" }, { name: "application_context", description: "Read application context" },
]) ])
expect( expect(
yield* registry.settle({ yield* settleTool(registry, {
sessionID, sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } }, call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
}), }),
).toEqual({ ).toEqual({
@ -89,21 +143,21 @@ describe("ApplicationTools", () => {
], ],
}, },
}) })
expect(contexts).toEqual([{ sessionID, id: "call-context", name: "application_context" }]) expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
}), }),
) )
it.effect("removes an application tool when its attachment scope closes", () => it.effect("removes an application tool when its registration scope closes", () =>
Effect.gen(function* () { Effect.gen(function* () {
const applications = yield* ApplicationTools.Service const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make() const scope = yield* Scope.make()
yield* applications.attach({ temporary: contextual([]) }).pipe(Scope.provide(scope)) yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["temporary"]) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"])
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
expect(yield* registry.definitions()).toEqual([]) expect(yield* toolDefinitions(registry)).toEqual([])
}), }),
) )
@ -111,70 +165,99 @@ describe("ApplicationTools", () => {
Effect.gen(function* () { Effect.gen(function* () {
const applications = yield* ApplicationTools.Service const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const attachmentScope = yield* Scope.make() const registrationScope = yield* Scope.make()
yield* applications.attach({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope)) yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope))
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"]) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
yield* Scope.close(attachmentScope, Exit.void) yield* Scope.close(registrationScope, Exit.void)
expect( expect(
yield* registry.settle({ yield* settleTool(registry, {
sessionID, sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } }, call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
}), }),
).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } }) ).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
}), }),
) )
it.effect("does not leak an attachment into an already closed scope", () => it.effect("does not leak a registration into an already closed scope", () =>
Effect.gen(function* () { Effect.gen(function* () {
const applications = yield* ApplicationTools.Service const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make() const scope = yield* Scope.make()
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
yield* applications.attach({ closed: contextual([]) }).pipe(Scope.provide(scope)) yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope))
expect(yield* registry.definitions()).toEqual([]) expect(yield* toolDefinitions(registry)).toEqual([])
}), }),
) )
it.effect("captures the attached record before later State rebuilds", () => it.effect("preserves an interrupted application registration until its scope closes", () =>
Effect.gen(function* () { Effect.gen(function* () {
const applications = yield* ApplicationTools.Service const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const attached = { stable: contextual([]) } const scope = yield* Scope.make()
yield* applications.attach(attached) const registered = yield* Deferred.make<void>()
Object.assign(attached, { late: contextual([]) }) const fiber = yield* applications
.register({ interrupted: contextual([]) })
.pipe(
Effect.andThen(Deferred.succeed(registered, undefined)),
Effect.andThen(Effect.never),
Scope.provide(scope),
Effect.forkChild,
)
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
yield* Effect.scoped(applications.attach({ temporary: contextual([]) })) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"])
yield* Scope.close(scope, Exit.void)
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["stable"]) expect(yield* toolDefinitions(registry)).toEqual([])
}), }),
) )
it.effect("settles with the current same-name application tool and restores earlier attachments", () => it.effect("captures the registered record before later State rebuilds", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service
const registered = { stable: contextual([]) }
yield* applications.register(registered)
Object.assign(registered, { late: contextual([]) })
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"])
}),
)
it.effect("settles with the current same-name application tool and restores earlier registrations", () =>
Effect.gen(function* () { Effect.gen(function* () {
const applications = yield* ApplicationTools.Service const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const firstContexts: Tool.Context[] = [] const firstContexts: Tool.Context[] = []
const secondContexts: Tool.Context[] = [] const secondContexts: Tool.Context[] = []
const scope = yield* Scope.make() const scope = yield* Scope.make()
yield* applications.attach({ contextual: contextual(firstContexts) }) yield* applications.register({ contextual: contextual(firstContexts) })
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"]) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
yield* applications.attach({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope)) yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
yield* registry.settle({ yield* settleTool(registry, {
sessionID, sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } }, call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
}) })
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
yield* registry.settle({ yield* settleTool(registry, {
sessionID, sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } }, call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
}) })
expect(secondContexts).toEqual([{ sessionID, id: "call-second", name: "contextual" }]) expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
expect(firstContexts).toEqual([{ sessionID, id: "call-first", name: "contextual" }]) expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
}), }),
) )
@ -182,32 +265,26 @@ describe("ApplicationTools", () => {
Effect.gen(function* () { Effect.gen(function* () {
const applications = yield* ApplicationTools.Service const applications = yield* ApplicationTools.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
const locationContexts: Tool.Context[] = [] const locationContexts: Tool.Context[] = []
const applicationContexts: Tool.Context[] = [] const applicationContexts: Tool.Context[] = []
const location = contextual(locationContexts) const location = contextual(locationContexts)
yield* transform((editor) => yield* registry.register({ shared: location })
editor.set("shared", { yield* applications.register({ shared: contextual(applicationContexts) })
tool: location.definition,
permission: { action: "question", resource: "*" },
execute: ({ parameters, sessionID, call }) =>
location.execute(parameters, { sessionID, id: call.id, name: call.name }),
}),
)
yield* applications.attach({ shared: contextual(applicationContexts) })
expect( expect(
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map( (yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map(
(definition) => definition.name, (definition) => definition.name,
), ),
).toEqual([]) ).toEqual([])
expect( expect(
yield* registry.settle({ yield* settleTool(registry, {
sessionID, sessionID,
agent,
assistantMessageID,
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } }, call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
}), }),
).toMatchObject({ result: { type: "content" } }) ).toMatchObject({ result: { type: "content" } })
expect(locationContexts).toEqual([{ sessionID, id: "call-shared", name: "shared" }]) expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
expect(applicationContexts).toEqual([]) expect(applicationContexts).toEqual([])
}), }),
) )

View file

@ -0,0 +1,153 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { Fff } from "#fff"
import { Search } from "@opencode-ai/core/filesystem/search"
import { testEffect } from "../lib/effect"
const it = testEffect(Search.defaultLayer)
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
Effect.acquireRelease(
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
(dir) =>
Effect.promise(() => fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })).pipe(
Effect.ignore,
),
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
describe("file.search", () => {
it.live("uses fff for Bun-backed grep", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
const search = yield* Search.Service
const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
expect(result.engine).toBe("fff")
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe("src/match.ts")
}),
)
it.live("keeps fuzzy file abbreviation matches", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "README.md"), "hello\n")
const search = yield* Search.Service
const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
expect(results).toContain("README.md")
}),
)
it.live("keeps empty file query candidates", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "README.md"), "hello\n")
yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n")
const search = yield* Search.Service
const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" })
expect(results).toContain("README.md")
expect(results).toContain("src/")
expect(results).not.toContain("")
}),
)
it.live("keeps paging grep results without an explicit limit", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "matches.txt"), Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""))
const search = yield* Search.Service
const result = yield* search.search({ cwd: dir, pattern: "needle" })
expect(result.items).toHaveLength(150)
}),
)
it.live("uses byte ranges for UTF-8 grep submatches", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "unicode.txt"), "éneedle\n")
const search = yield* Search.Service
const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
expect(result.items[0]?.submatches[0]?.match.text).toBe("needle")
}),
)
it.live("post-filters fff grep include matches", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "src", "match.ts"), "needle\n")
yield* write(path.join(dir, "src", "match.txt"), "needle\n")
const search = yield* Search.Service
const result = yield* search.search({ cwd: dir, pattern: "needle", glob: ["*.ts"], limit: 10 })
expect(result.engine).toBe("fff")
expect(result.items.map((entry) => entry.path.text)).toEqual(["src/match.ts"])
}),
)
it.live("keeps fff grep include no-match results", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "src", "match.ts"), "needle\n")
const search = yield* Search.Service
const result = yield* search.search({ cwd: dir, pattern: "missing", glob: ["*.ts"], limit: 10 })
expect(result.engine).toBe("fff")
expect(result.items).toEqual([])
}),
)
it.live("post-filters fff glob matches", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "src", "match.ts"), "export const value = 1\n")
yield* write(path.join(dir, "src", "match.txt"), "hello\n")
const search = yield* Search.Service
const result = yield* search.glob({ cwd: dir, pattern: "**/*.ts", limit: 10 })
expect(result.files).toEqual([path.join(dir, "src", "match.ts")])
}),
)
it.live("tracks an opened file against its originating query", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n")
yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n")
const search = yield* Search.Service
const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
expect(results).toContain("alpha-target-two.ts")
// open() records the query->file association in fff's history db via the
// live picker. It must resolve a remembered file and run without error.
yield* search.open({ cwd: dir, file: "alpha-target-two.ts" })
}),
)
})

View file

@ -0,0 +1,20 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Effect } from "effect"
export const toolIdentity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
}
export const toolDefinitions = (
registry: ToolRegistry.Interface,
permissions?: Parameters<typeof registry.materialize>[0],
) => registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))

View file

@ -10,6 +10,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolDefinitions } from "./lib/tool"
import { FSUtil } from "../src/fs-util" import { FSUtil } from "../src/fs-util"
import { Auth } from "../src/auth" import { Auth } from "../src/auth"
import { EventV2 } from "../src/event" import { EventV2 } from "../src/event"
@ -50,11 +51,11 @@ describe("LocationServiceMap", () => {
).pipe( ).pipe(
Effect.flatMap(([blocked, allowed]) => Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* (yield* ApplicationTools.Service).attach({ yield* (yield* ApplicationTools.Service).register({
application_context: Tool.make({ application_context: Tool.make({
description: "Read application context", description: "Read application context",
parameters: Schema.Struct({}), input: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }), output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }), execute: () => Effect.succeed({ ok: true }),
}), }),
}) })
@ -77,7 +78,7 @@ describe("LocationServiceMap", () => {
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
return { return {
providers: yield* catalog.provider.all(), providers: yield* catalog.provider.all(),
tools: yield* (yield* ToolRegistry.Service).definitions(), tools: yield* toolDefinitions(yield* ToolRegistry.Service),
} }
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) }))) }).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })))

View file

@ -161,6 +161,21 @@ describe("PermissionV2", () => {
}), }),
) )
it.effect("allows managed output reads without granting external directory access", () =>
Effect.gen(function* () {
yield* setup([
{ action: "*", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" })
expect(
yield* service.ask(assertion({ action: "external_directory", resources: ["/tmp/tool-output/*"] })),
).toMatchObject({ effect: "deny" })
}),
)
it.effect("uses build permissions when the Session agent is omitted", () => it.effect("uses build permissions when the Session agent is omitted", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup() yield* setup()

View file

@ -30,11 +30,11 @@ describe("public native OpenCode API", () => {
expect(Session.ID.create()).toStartWith("ses_") expect(Session.ID.create()).toStartWith("ses_")
expect(Session.MessageID.create()).toStartWith("msg_") expect(Session.MessageID.create()).toStartWith("msg_")
expect(yield* opencode.sessions.list()).toBeArray() expect(yield* opencode.sessions.list()).toBeArray()
yield* opencode.tools.attach({ yield* opencode.tools.register({
public_tool: Tool.make({ public_tool: Tool.make({
description: "Public tool", description: "Public tool",
parameters: Schema.Struct({}), input: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }), output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }), execute: () => Effect.succeed({ ok: true }),
}), }),
}) })

View file

@ -0,0 +1,13 @@
import { describe, expect, it } from "bun:test"
import { Tool } from "@opencode-ai/core/public"
import { Effect } from "effect"
describe("public Tool API", () => {
it("keeps the public registration capability narrow", () => {
const tools = {
register: () => Effect.void,
} satisfies Tool.Interface
expect(Object.keys(tools)).toEqual(["register"])
})
})

View file

@ -1,65 +1,70 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Tool, ToolFailure } from "@opencode-ai/llm" import { Tool } from "@opencode-ai/core/tool/tool"
import { PermissionV2 } from "@opencode-ai/core/permission" import { AgentV2 } from "@opencode-ai/core/agent"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const bounds: ToolOutputStore.BoundInput[] = [] const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore = Layer.mock(ToolOutputStore.Service, { const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })), bound: (input) => {
if (input.toolCallID === "call-retention-failure") return Effect.fail(retentionFailure)
return Effect.sync(() => bounds.push(input)).pipe(
Effect.as(
input.toolCallID === "call-bounded"
? {
output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
}
: { output: input.output, outputPaths: [] },
),
)
},
})
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
const it = testEffect(registry)
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"),
}
const sessionID = SessionV2.ID.make("ses_registry")
const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({
sessionID,
...identity,
call: { type: "tool-call", id, name, input: { text: name } },
}) })
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(ApplicationTools.layer),
Layer.provide(outputStore),
)
const it = testEffect(Layer.mergeAll(permission, registry))
const echo = Tool.make({ const make = (permission?: string) => {
description: "Echo text", const tool = Tool.make({
parameters: Schema.Struct({ text: Schema.String }), description: "Echo text",
success: Schema.Struct({ text: Schema.String }), input: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }), output: Schema.Struct({ text: Schema.String }),
}) execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
})
return permission ? Tool.withPermission(tool, permission) : tool
}
describe("ToolRegistry", () => { describe("ToolRegistry", () => {
it.effect("matches V1 whole-tool filtering, edit aliases, and ordered wildcard precedence", () => it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
const transform = yield* registry.transform() yield* service.register({
const sessionID = SessionV2.ID.make("ses_registry_filter") question: make(),
yield* transform((editor) => { bash: make(),
editor.set("question", { tool: echo }) edit: make("edit"),
editor.set("bash", { tool: echo }) write: make("edit"),
editor.set("edit", { tool: echo }) apply_patch: make("edit"),
editor.set("write", { tool: echo })
editor.set("apply_patch", { tool: echo })
}) })
const names = (rules: Parameters<ToolRegistry.Interface["materialize"]>[0]) =>
const names = (rules: PermissionV2.Ruleset) => toolDefinitions(service, rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
registry.definitions(rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([ expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash", "bash",
@ -67,253 +72,374 @@ describe("ToolRegistry", () => {
"write", "write",
"apply_patch", "apply_patch",
]) ])
expect( expect(
yield* names([ yield* names([
{ action: "*", resource: "*", effect: "deny" }, { action: "*", resource: "*", effect: "deny" },
{ action: "question", resource: "private", effect: "allow" }, { action: "question", resource: "private", effect: "allow" },
]), ]),
).toEqual(["question"]) ).toEqual(["question"])
expect( expect(
yield* names([ yield* names([
{ action: "question", resource: "private", effect: "allow" }, { action: "question", resource: "private", effect: "allow" },
{ action: "*", resource: "*", effect: "deny" }, { action: "*", resource: "*", effect: "deny" },
]), ]),
).toEqual([]) ).toEqual([])
expect(yield* names([{ action: "question", resource: "*", effect: "ask" }])).toContain("question")
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"]) expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
expect(
yield* names([
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: "*.md", effect: "ask" },
]),
).toEqual(["question", "bash", "edit", "write", "apply_patch"])
expect(
yield* names([
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
]),
).toEqual(["question", "bash"])
}), }),
) )
it.effect("settles only through concrete leaf authorization, not catalog visibility", () => it.effect("keeps permission decoration isolated between registrations", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
const transform = yield* registry.transform() const shared = make()
const sessionID = SessionV2.ID.make("ses_registry_stale") yield* service.register({ first: shared })
let executed = false yield* service.register({ second: Tool.withPermission(shared, "edit") })
yield* transform((editor) => Tool.withPermission(shared, "question")
editor.set("question", {
tool: echo,
permission: { action: "question", resource: "*" },
authorize: ({ assertPermission }) =>
assertPermission({ action: "question", resources: ["actual"] }).pipe(
Effect.mapError(() => new ToolFailure({ message: "Denied" })),
),
execute: () =>
Effect.sync(() => {
executed = true
return { text: "unexpected" }
}),
}),
)
expect( expect(
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map((tool) => tool.name), (yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
).toEqual([]) (definition) => definition.name,
expect( ),
yield* registry.settle({ ).toEqual(["first"])
sessionID,
call: { type: "tool-call", id: "call-stale", name: "question", input: { text: "hello" } },
}),
).toMatchObject({ result: { type: "json", value: { text: "unexpected" } } })
expect(assertions.at(-1)).toMatchObject({ action: "question", resources: ["actual"] })
expect(executed).toBe(true)
}), }),
) )
it.effect("rebuilds advertised definitions when a scoped transform closes", () => it.effect("reuses model definitions across provider turns", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const first = yield* toolDefinitions(service)
const second = yield* toolDefinitions(service)
expect(second[0]).toBe(first[0])
}),
)
it.effect("removes a scoped registration", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make() const scope = yield* Scope.make()
const transform = yield* registry.transform().pipe(Scope.provide(scope)) yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
yield* transform((editor) => editor.set("echo", { tool: echo, authorize: () => Effect.void }))
expect(yield* registry.definitions()).toMatchObject([{ name: "echo", description: "Echo text" }])
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
expect(yield* registry.definitions()).toEqual([]) expect(yield* toolDefinitions(service)).toEqual([])
}), }),
) )
it.effect("returns an error result for an unknown tool", () => it.effect("preserves an interrupted registration until its scope closes", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
const registered = yield* Deferred.make<void>()
const fiber = yield* service
.register({ echo: make() })
.pipe(
Effect.andThen(Deferred.succeed(registered, undefined)),
Effect.andThen(Effect.never),
Scope.provide(scope),
Effect.forkChild,
)
yield* Deferred.await(registered)
yield* Fiber.interrupt(fiber)
expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
}),
)
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
failed: Tool.make({
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}),
})
expect( expect(
yield* registry.execute({ yield* executeTool(service, {
sessionID: SessionV2.ID.make("ses_registry_test"), sessionID,
call: { type: "tool-call", id: "call-missing", name: "missing", input: {} }, ...identity,
call: { type: "tool-call", id: "failed", name: "failed", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "missing", name: "missing", input: {} },
}), }),
).toEqual({ type: "error", value: "Unknown tool: missing" }) ).toEqual({ type: "error", value: "Unknown tool: missing" })
}),
)
it.effect("does not execute a tool when authorization fails", () => yield* service.register({
Effect.gen(function* () { defect: Tool.make({
const registry = yield* ToolRegistry.Service description: "Defect",
let executed = false input: Schema.Struct({}),
const transform = yield* registry.transform() output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
yield* transform((editor) =>
editor.set("denied", {
authorize: () => Effect.fail(new ToolFailure({ message: "Denied" })),
tool: Tool.make({
description: "Denied tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
execute: () =>
Effect.sync(() => {
executed = true
return { ok: true }
}),
}),
}), }),
) })
expect( expect(
yield* registry.execute({ yield* service.materialize().pipe(
sessionID: SessionV2.ID.make("ses_registry_test"), Effect.flatMap((materialized) =>
call: { type: "tool-call", id: "call-denied", name: "denied", input: {} }, materialized.settle({
}), sessionID,
).toEqual({ type: "error", value: "Denied" }) ...identity,
expect(executed).toBe(false) call: { type: "tool-call", id: "defect", name: "defect", input: {} },
}),
),
Effect.catchDefect(Effect.succeed),
),
).toBe("unexpected executor defect")
}), }),
) )
it.effect("binds invocation identity while preserving leaf-owned permission inputs", () => it.effect("propagates retention failures through settlement", () =>
Effect.gen(function* () { Effect.gen(function* () {
assertions.length = 0 const service = yield* ToolRegistry.Service
denyAction = undefined yield* service.register({ echo: make() })
const registry = yield* ToolRegistry.Service const materialized = yield* service.materialize()
const transform = yield* registry.transform() const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
const sessionID = SessionV2.ID.make("ses_registry_context")
yield* transform((editor) => expect(Exit.isFailure(exit)).toBe(true)
editor.set("context", { if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
tool: Tool.make({
description: "Context tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
}),
execute: ({ assertPermission, call, source }) =>
assertPermission({
action: "inspect",
resources: [call.id],
save: ["*"],
metadata: { tool: call.name },
}).pipe(
Effect.as({ ok: source === undefined }),
Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" }))),
),
}),
)
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
}),
).toEqual({ type: "json", value: { ok: true } })
expect(assertions).toEqual([
{
sessionID,
action: "inspect",
resources: ["call-context"],
save: ["*"],
metadata: { tool: "context" },
},
])
expect(assertions[0]).not.toHaveProperty("source")
}), }),
) )
it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () => it.effect("exposes settlement only through materialization", () =>
Effect.gen(function* () { Effect.gen(function* () {
assertions.length = 0 const service = yield* ToolRegistry.Service
denyAction = "execute" expect("definitions" in service).toBe(false)
let executed = false expect("execute" in service).toBe(false)
const registry = yield* ToolRegistry.Service expect("settle" in service).toBe(false)
const transform = yield* registry.transform() expect(typeof service.materialize).toBe("function")
yield* transform((editor) =>
editor.set("ordered", {
tool: Tool.make({
description: "Ordered policy tool",
parameters: Schema.Struct({}),
success: Schema.Struct({ ok: Schema.Boolean }),
}),
execute: ({ assertPermission }) =>
Effect.gen(function* () {
yield* assertPermission({ action: "external_directory", resources: ["/outside/*"] })
yield* assertPermission({ action: "execute", resources: ["pwd"] })
executed = true
return { ok: true }
}).pipe(Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" })))),
}),
)
expect(
yield* registry.execute({
sessionID: SessionV2.ID.make("ses_registry_context"),
call: { type: "tool-call", id: "call-ordered", name: "ordered", input: {} },
}),
).toEqual({ type: "error", value: "Denied" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "execute"])
expect(executed).toBe(false)
denyAction = undefined
}), }),
) )
it.effect("settles encoded structured output with canonical projected content", () => it.effect("passes complete invocation identity to the canonical handler", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* service.register({
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
})
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }])
}),
)
it.effect("encodes output and applies generic settlement bounding", () =>
Effect.gen(function* () { Effect.gen(function* () {
bounds.length = 0 bounds.length = 0
const registry = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service
const transform = yield* registry.transform() yield* service.register({ bounded: make() })
expect(
yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
}),
).toEqual({
result: { type: "text", value: "bounded reference" },
output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
})
expect(bounds).toHaveLength(1)
}),
)
yield* transform((editor) => it.effect("enforces transformed codecs at execution and projection boundaries", () =>
editor.set("projected", { Effect.gen(function* () {
tool: Tool.make({ const service = yield* ToolRegistry.Service
description: "Projected tool", const executed: string[] = []
parameters: Schema.Struct({ prefix: Schema.String }), const Transformed = Schema.Boolean.pipe(
success: Schema.Struct({ count: Schema.NumberFromString }), Schema.decodeTo(Schema.String, {
execute: () => Effect.succeed({ count: 2 }), decode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
toModelOutput: ({ callID, parameters, output }) => [ encode: SchemaGetter.transform((value) => value === "yes"),
{ type: "text", text: `${callID}:${parameters.prefix}:${output.count}` },
],
}),
}), }),
) )
yield* service.register({
transformed: Tool.make({
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
}),
})
expect( expect(
yield* registry.settle({ yield* executeTool(service, {
sessionID: SessionV2.ID.make("ses_registry_test"), sessionID,
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } }, ...identity,
call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
}),
).toEqual({ type: "text", value: "true" })
expect(executed).toEqual(["yes"])
expect(
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
}),
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
expect(executed).toEqual(["yes"])
yield* service.register({
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
}),
),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}), }),
).toMatchObject({
result: { type: "text", value: "call-projected:count:2" },
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
}) })
expect(bounds).toEqual([ expect(
{ yield* executeTool(service, {
sessionID: SessionV2.ID.make("ses_registry_test"), sessionID,
toolCallID: "call-projected", ...identity,
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] }, call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} },
}, }),
]) ).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") })
}),
)
it.effect("executes the unchanged registration advertised for a provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize()
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
}),
)
it.effect("rejects a call when its advertised registration was removed", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("rejects only the replaced name from a multi-tool provider turn", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ first: make(), second: make() })
const materialized = yield* service.materialize()
yield* service.register({ first: make() })
expect((yield* materialized.settle(call("first"))).result).toEqual({
type: "error",
value: "Stale tool call: first",
})
expect((yield* materialized.settle(call("second"))).result).toEqual({ type: "text", value: "second" })
}),
)
it.effect("treats revealing a previous overlay as stale", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const overlay = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(overlay))
const materialized = yield* service.materialize()
yield* Scope.close(overlay, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
integrated.effect("rejects an application call after a Location override is registered", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const service = yield* ToolRegistry.Service
yield* applications.register({ echo: make() })
const materialized = yield* service.materialize()
yield* service.register({ echo: make() })
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
integrated.effect("rejects a Location call after removal reveals an application registration", () =>
Effect.gen(function* () {
const applications = yield* ApplicationTools.Service
const service = yield* ToolRegistry.Service
yield* applications.register({ echo: make() })
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
yield* Scope.close(scope, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
}),
)
it.effect("keeps captured execution running after registration mutation", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* service
.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as({ text })),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
}),
})
.pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Scope.close(scope, Exit.void)
yield* service.register({ echo: make() })
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(settlement)).toMatchObject({ result: { type: "text", value: "echo" } })
}), }),
) )
}) })

View file

@ -4,7 +4,6 @@ import {
LLMError, LLMError,
LLMEvent, LLMEvent,
Model, Model,
Tool,
TransportReason, TransportReason,
InvalidRequestReason, InvalidRequestReason,
type LLMClientShape, type LLMClientShape,
@ -38,7 +37,7 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction" import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { NativeTool } from "@opencode-ai/core/tool/native" import { Tool } from "@opencode-ai/core/tool/tool"
import { import {
SessionContextEpochTable, SessionContextEpochTable,
SessionInputTable, SessionInputTable,
@ -110,7 +109,7 @@ const recoveryModel = Model.make({
provider: "fake", provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }), route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
}) })
const authorizations: ToolRegistry.AuthorizeInput[] = [] const authorizations: Tool.Context[] = []
const executions: string[] = [] const executions: string[] = []
const permission = Layer.succeed( const permission = Layer.succeed(
PermissionV2.Service, PermissionV2.Service,
@ -132,38 +131,31 @@ const registry = ToolRegistry.layer.pipe(
const agents = AgentV2.layer const agents = AgentV2.layer
const echo = Layer.effectDiscard( const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) => ToolRegistry.Service.use((registry) =>
registry.contribute((editor) => { registry.register({
;(editor.set("echo", { echo: Tool.make({
authorize: (input) => description: "Echo text",
Effect.sync(() => { input: Schema.Struct({ text: Schema.String }),
authorizations.push(input) output: Schema.Struct({ text: Schema.String }),
}), toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
tool: Tool.make({ execute: ({ text }, context) =>
description: "Echo text", Effect.gen(function* () {
parameters: Schema.Struct({ text: Schema.String }), authorizations.push(context)
success: Schema.Struct({ text: Schema.String }), executions.push(text)
toModelOutput: ({ output }) => [{ type: "text", text: output.text }], activeToolExecutions++
execute: ({ text }) => maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
Effect.gen(function* () { if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
executions.push(text) yield* Deferred.succeed(toolExecutionsStarted, undefined)
activeToolExecutions++ }
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { return { text }
yield* Deferred.succeed(toolExecutionsStarted, undefined) }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
} }),
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) defect: Tool.make({
return { text } description: "Fail unexpectedly",
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), input: Schema.Struct({}),
}), output: Schema.Struct({}),
execute: () => Effect.die("unexpected tool defect"),
}), }),
editor.set("defect", {
tool: Tool.make({
description: "Fail unexpectedly",
parameters: Schema.Struct({}),
success: Schema.Struct({}),
execute: () => Effect.die("unexpected tool defect"),
}),
}))
}), }),
), ),
).pipe(Layer.provide(registry)) ).pipe(Layer.provide(registry))
@ -567,12 +559,12 @@ describe("SessionRunnerLLM", () => {
yield* setup yield* setup
const applicationTools = yield* ApplicationTools.Service const applicationTools = yield* ApplicationTools.Service
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const contexts: NativeTool.Context[] = [] const contexts: Tool.Context[] = []
yield* applicationTools.attach({ yield* applicationTools.register({
application_context: NativeTool.make({ application_context: Tool.make({
description: "Read application context", description: "Read application context",
parameters: Schema.Struct({ query: Schema.String }), input: Schema.Struct({ query: Schema.String }),
success: Schema.Struct({ answer: Schema.String }), output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) => execute: ({ query }, context) =>
Effect.sync(() => { Effect.sync(() => {
contexts.push(context) contexts.push(context)
@ -594,7 +586,14 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID) yield* session.resume(sessionID)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context") expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
expect(contexts).toEqual([{ sessionID, id: "call-application", name: "application_context" }]) expect(contexts).toEqual([
{
sessionID,
agent: AgentV2.ID.make("build"),
assistantMessageID: expect.stringMatching(/^msg_/),
toolCallID: "call-application",
},
])
expect(yield* session.context(sessionID)).toMatchObject([ expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use application context" }, { type: "user", text: "Use application context" },
{ {
@ -1783,7 +1782,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2) expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, call: { id: "call-echo", name: "echo" } }]) expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }])
expect(executions).toEqual(["hello"]) expect(executions).toEqual(["hello"])
expect(yield* session.context(sessionID)).toMatchObject([ expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Echo this" }, { type: "user", text: "Echo this" },
@ -2937,7 +2936,7 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("durably settles unexpected local tool defects before continuing", () => it.effect("propagates unexpected local tool defects operationally", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
@ -2951,12 +2950,11 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }),
], ],
[],
] ]
yield* session.resume(sessionID) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect")
expect(requests).toHaveLength(2) expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([ expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call defect" }, { type: "user", text: "Call defect" },
{ {
@ -2965,7 +2963,10 @@ describe("SessionRunnerLLM", () => {
{ {
type: "tool", type: "tool",
id: "call-defect", id: "call-defect",
state: { status: "error", error: { message: "unexpected tool defect" } }, state: {
status: "error",
error: { type: "unknown", message: "Tool execution failed: unexpected tool defect" },
},
}, },
], ],
}, },
@ -2979,17 +2980,15 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const questions = yield* QuestionV2.Service const questions = yield* QuestionV2.Service
const transform = yield* registry.transform() yield* registry.register({
yield* transform((editor) => question: Tool.make({
editor.set("question", { description: "Ask the user",
tool: Tool.make({ input: Schema.Struct({}),
description: "Ask the user", output: Schema.Struct({}),
parameters: Schema.Struct({}), execute: (_, context) =>
success: Schema.Struct({}), questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
}),
execute: ({ sessionID }) => questions.ask({ sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
}), }),
) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false }) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false })
requests.length = 0 requests.length = 0

View file

@ -0,0 +1,34 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
describe("State", () => {
it.effect("commits a transform atomically when its updater is interrupted", () =>
Effect.gen(function* () {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let block = true
const state = State.create({
initial: () => ({ values: [] as string[] }),
editor: (draft) => ({ add: (value: string) => draft.values.push(value) }),
finalize: () =>
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
})
const scope = yield* Scope.make()
const update = yield* state.transform().pipe(Scope.provide(scope))
const fiber = yield* update((editor) => editor.add("registered")).pipe(Effect.forkChild)
yield* Deferred.await(rebuilding)
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
block = false
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interruption)
expect(state.get().values).toEqual(["registered"])
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual([])
}),
)
})

View file

@ -1,7 +1,7 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect" import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@ -14,6 +14,7 @@ import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test") const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
@ -92,6 +93,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const patch = ApplyPatchTool.layer.pipe( const patch = ApplyPatchTool.layer.pipe(
Layer.provide(registry), Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution), Layer.provide(resolution),
Layer.provide(mutation), Layer.provide(mutation),
Layer.provide(filesystem), Layer.provide(filesystem),
@ -103,6 +105,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const call = (patchText: string, id = "call-apply-patch") => ({ const call = (patchText: string, id = "call-apply-patch") => ({
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } }, call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } },
}) })
@ -129,8 +132,9 @@ describe("ApplyPatchTool", () => {
Effect.andThen( Effect.andThen(
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"]) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["apply_patch"])
const settled = yield* registry.settle( const settled = yield* settleTool(
registry,
call( call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch", "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
), ),
@ -146,7 +150,7 @@ describe("ApplyPatchTool", () => {
{ type: "delete", resource: "remove.txt" }, { type: "delete", resource: "remove.txt" },
], ],
}) })
expect(assertions).toEqual([ expect(assertions).toMatchObject([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
]) ])
expect(readsBeforeEditApproval).toBe(0) expect(readsBeforeEditApproval).toBe(0)
@ -175,7 +179,8 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* registry.execute( yield* executeTool(
registry,
call( call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch", "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
), ),
@ -203,7 +208,8 @@ describe("ApplyPatchTool", () => {
withTool(active.path, (registry) => withTool(active.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* registry.execute( yield* executeTool(
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`), call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
), ),
).toMatchObject({ type: "text" }) ).toMatchObject({ type: "text" })
@ -236,7 +242,8 @@ describe("ApplyPatchTool", () => {
withTool(active.path, (registry) => withTool(active.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* registry.execute( yield* executeTool(
registry,
call( call(
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`, `*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
), ),
@ -266,7 +273,8 @@ describe("ApplyPatchTool", () => {
return withTool(tmp.path, (registry) => return withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* registry.execute( yield* executeTool(
registry,
call( call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch", "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
), ),
@ -291,7 +299,8 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* registry.execute( yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"), call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
), ),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" }) ).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
@ -315,7 +324,10 @@ describe("ApplyPatchTool", () => {
return withTool(tmp.path, (registry) => return withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* registry.execute(call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch")), yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" }) ).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n") expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
}), }),
@ -325,7 +337,7 @@ describe("ApplyPatchTool", () => {
), ),
) )
it.live("reports earlier sequential applications when a later commit fails", () => it.live("preserves a later commit defect after earlier sequential applications", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
@ -338,13 +350,13 @@ describe("ApplyPatchTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* registry.execute( Exit.isFailure(
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"), yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
).pipe(Effect.exit),
), ),
).toEqual({ ).toBe(true)
type: "error",
value: "Patch partially applied before failing at second.txt. Applied: first.txt",
})
expect(yield* exists(first)).toBe(false) expect(yield* exists(first)).toBe(false)
expect(yield* exists(second)).toBe(true) expect(yield* exists(second)).toBe(true)
}), }),
@ -370,11 +382,10 @@ describe("ApplyPatchTool", () => {
yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")])) yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")]))
yield* withTool(tmp.path, (registry) => yield* withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
const run = yield* registry const run = yield* executeTool(
.execute( registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"), call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
) ).pipe(Effect.forkChild)
.pipe(Effect.forkChild)
yield* Deferred.await(removeStarted!) yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild) const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
yield* Deferred.succeed(releaseRemove!, undefined) yield* Deferred.succeed(releaseRemove!, undefined)

View file

@ -13,11 +13,11 @@ import { AppProcess } from "@opencode-ai/core/process"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash" import { BashTool } from "@opencode-ai/core/tool/bash"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_bash_tool_test") const sessionID = SessionV2.ID.make("ses_bash_tool_test")
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
@ -27,7 +27,6 @@ const runs: Array<{
readonly shell?: string | boolean readonly shell?: string | boolean
readonly options?: AppProcess.RunOptions readonly options?: AppProcess.RunOptions
}> = [] }> = []
const truncations: ToolOutputStore.TruncateInput[] = []
let denyAction: string | undefined let denyAction: string | undefined
let result: AppProcess.RunResult = { let result: AppProcess.RunResult = {
command: "mock", command: "mock",
@ -39,8 +38,6 @@ let result: AppProcess.RunResult = {
} }
let runFailure: AppProcess.AppProcessError | undefined let runFailure: AppProcess.AppProcessError | undefined
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const permission = Layer.succeed( const permission = Layer.succeed(
PermissionV2.Service, PermissionV2.Service,
@ -70,16 +67,6 @@ const appProcess = Layer.succeed(
}), }),
} as unknown as AppProcess.Interface), } as unknown as AppProcess.Interface),
) )
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const config = Layer.succeed( const config = Layer.succeed(
Config.Service, Config.Service,
Config.Service.of({ Config.Service.of({
@ -90,7 +77,6 @@ const config = Layer.succeed(
const reset = () => { const reset = () => {
assertions.length = 0 assertions.length = 0
runs.length = 0 runs.length = 0
truncations.length = 0
denyAction = undefined denyAction = undefined
runFailure = undefined runFailure = undefined
afterPermission = () => Effect.void afterPermission = () => Effect.void
@ -102,7 +88,6 @@ const reset = () => {
stdoutTruncated: false, stdoutTruncated: false,
stderrTruncated: false, stderrTruncated: false,
} }
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
} }
const withTool = <A, E, R>( const withTool = <A, E, R>(
@ -123,7 +108,6 @@ const withTool = <A, E, R>(
Layer.provide(mutation), Layer.provide(mutation),
Layer.provide(filesystem), Layer.provide(filesystem),
Layer.provide(processLayer), Layer.provide(processLayer),
Layer.provide(resources),
Layer.provide(config), Layer.provide(config),
) )
return Effect.gen(function* () { return Effect.gen(function* () {
@ -131,8 +115,9 @@ const withTool = <A, E, R>(
}).pipe(Effect.provide(Layer.mergeAll(registry, bash))) }).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
} }
const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({ const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "bash", input }, call: { type: "tool-call" as const, id, name: "bash", input },
}) })
@ -146,11 +131,13 @@ describe("BashTool", () => {
reset() reset()
return withTool(tmp.path, (registry) => return withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
const definitions = yield* registry.definitions() const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(yield* registry.definitions([{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({ expect(
yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })),
).toEqual({
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
output: { output: {
structured: { structured: {
@ -168,7 +155,7 @@ describe("BashTool", () => {
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES, maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES, maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
}) })
expect(assertions).toEqual([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }]) expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}), }),
) )
}, },
@ -182,7 +169,9 @@ describe("BashTool", () => {
(tmp) => { (tmp) => {
reset() reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe( return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))), Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen( Effect.andThen(
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])), Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
), ),
@ -206,7 +195,9 @@ describe("BashTool", () => {
}).pipe(Effect.orDie) }).pipe(Effect.orDie)
: Effect.void : Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe( return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))), Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen( Effect.andThen(
Effect.sync(() => { Effect.sync(() => {
expect(runs).toEqual([]) expect(runs).toEqual([])
@ -227,7 +218,7 @@ describe("BashTool", () => {
reset() reset()
return withTool( return withTool(
tmp.path, tmp.path,
(registry) => registry.settle(call({ command: "printf core-bash" })), (registry) => settleTool(registry, call({ command: "printf core-bash" })),
AppProcess.defaultLayer, AppProcess.defaultLayer,
).pipe( ).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
@ -254,7 +245,7 @@ describe("BashTool", () => {
([active, outside]) => { ([active, outside]) => {
reset() reset()
return withTool(active.path, (registry) => return withTool(active.path, (registry) =>
registry.execute(call({ command: "pwd", workdir: outside.path })), executeTool(registry, call({ command: "pwd", workdir: outside.path })),
).pipe( ).pipe(
Effect.andThen( Effect.andThen(
Effect.sync(() => { Effect.sync(() => {
@ -281,13 +272,15 @@ describe("BashTool", () => {
Effect.gen(function* () { Effect.gen(function* () {
reset() reset()
denyAction = "external_directory" denyAction = "external_directory"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd", workdir: outside.path }))) yield* withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"]) expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
expect(runs).toEqual([]) expect(runs).toEqual([])
reset() reset()
denyAction = "bash" denyAction = "bash"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd" }))) yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["bash"]) expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toEqual([]) expect(runs).toEqual([])
}), }),
@ -305,7 +298,7 @@ describe("BashTool", () => {
reset() reset()
denyAction = "external_directory" denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt") const target = path.join(outside.path, "secret.txt")
return withTool(active.path, (registry) => registry.settle(call({ command: `cat ${target}` }))).pipe( return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["bash"]) expect(assertions.map((item) => item.action)).toEqual(["bash"])
@ -327,19 +320,13 @@ describe("BashTool", () => {
), ),
) )
it.live("keeps non-zero exits useful and exposes managed overflow by path", () => it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
reset() reset()
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") } result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
truncate = (input) => return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(settled.result).toMatchObject({ expect(settled.result).toMatchObject({
@ -350,13 +337,9 @@ describe("BashTool", () => {
command: "false", command: "false",
cwd: realpathSync(tmp.path), cwd: realpathSync(tmp.path),
exitCode: 7, exitCode: 7,
truncated: true, output: "HEAD full output TAIL",
outputPath: "/tmp/tool-output/tool_opaque", truncated: false,
}) })
expect(settled.outputPaths).toEqual(["/tmp/tool-output/tool_opaque"])
expect(truncations).toMatchObject([
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
])
}), }),
), ),
) )
@ -371,7 +354,7 @@ describe("BashTool", () => {
(tmp) => { (tmp) => {
reset() reset()
result = { ...result, stdoutTruncated: true } result = { ...result, stdoutTruncated: true }
return withTool(tmp.path, (registry) => registry.settle(call({ command: "verbose" }))).pipe( return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true }) expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
@ -394,7 +377,7 @@ describe("BashTool", () => {
(tmp) => { (tmp) => {
reset() reset()
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") }) runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
return withTool(tmp.path, (registry) => registry.settle(call({ command: "sleep 60", timeout: 10 }))).pipe( return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
expect(settled.result).toMatchObject({ expect(settled.result).toMatchObject({

View file

@ -15,6 +15,7 @@ import { EditTool } from "@opencode-ai/core/tool/edit"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_edit_tool_test") const sessionID = SessionV2.ID.make("ses_edit_tool_test")
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
@ -82,6 +83,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const edit = EditTool.layer.pipe( const edit = EditTool.layer.pipe(
Layer.provide(registry), Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution), Layer.provide(resolution),
Layer.provide(mutation), Layer.provide(mutation),
Layer.provide(filesystem), Layer.provide(filesystem),
@ -91,8 +93,9 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit))) }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit)))
} }
const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({ const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "edit", input }, call: { type: "tool-call" as const, id, name: "edit", input },
}) })
@ -109,9 +112,12 @@ describe("EditTool", () => {
Effect.andThen( Effect.andThen(
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["edit"]) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit"])
expect(yield* registry.definitions([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([]) expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual(
const settled = yield* registry.settle( [],
)
const settled = yield* settleTool(
registry,
call({ path: "hello.txt", oldString: "before", newString: "after" }), call({ path: "hello.txt", oldString: "before", newString: "after" }),
) )
expect(settled.result).toEqual({ expect(settled.result).toEqual({
@ -126,7 +132,7 @@ describe("EditTool", () => {
replacements: 1, replacements: 1,
}) })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))]) expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
}), }),
), ),
@ -146,7 +152,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before")).pipe( return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen( Effect.andThen(
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
registry.execute(call({ path: target, oldString: "before", newString: "after" })), executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
), ),
), ),
Effect.andThen((result) => Effect.andThen((result) =>
@ -171,7 +177,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before")).pipe( return Effect.promise(() => fs.writeFile(target, "before")).pipe(
Effect.andThen( Effect.andThen(
withTool(active.path, (registry) => withTool(active.path, (registry) =>
registry.execute(call({ path: target, oldString: "before", newString: "after" })), executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
), ),
), ),
Effect.andThen((result) => Effect.andThen((result) =>
@ -202,7 +208,7 @@ describe("EditTool", () => {
denyAction = "external_directory" denyAction = "external_directory"
expect( expect(
yield* withTool(active.path, (registry) => yield* withTool(active.path, (registry) =>
registry.execute(call({ path: external, oldString: "before", newString: "after" })), executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
), ),
).toEqual({ ).toEqual({
type: "error", type: "error",
@ -216,7 +222,7 @@ describe("EditTool", () => {
denyAction = "edit" denyAction = "edit"
expect( expect(
yield* withTool(active.path, (registry) => yield* withTool(active.path, (registry) =>
registry.execute(call({ path: external, oldString: "before", newString: "after" })), executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
), ),
).toEqual({ ).toEqual({
type: "error", type: "error",
@ -245,10 +251,12 @@ describe("EditTool", () => {
Effect.andThen( Effect.andThen(
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
const matching = yield* registry.execute( const matching = yield* executeTool(
registry,
call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }), call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }),
) )
const missing = yield* registry.execute( const missing = yield* executeTool(
registry,
call({ path: "secret.txt", oldString: "not present", newString: "replacement" }), call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
) )
@ -277,26 +285,26 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "same" })), yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })),
).toEqual({ ).toEqual({
type: "error", type: "error",
value: "No changes to apply: oldString and newString are identical.", value: "No changes to apply: oldString and newString are identical.",
}) })
expect( expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "", newString: "after" })), yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })),
).toEqual({ ).toEqual({
type: "error", type: "error",
value: "oldString must not be empty. Use write to create or overwrite a file.", value: "oldString must not be empty. Use write to create or overwrite a file.",
}) })
expect( expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "missing", newString: "after" })), yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })),
).toEqual({ ).toEqual({
type: "error", type: "error",
value: value:
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.", "Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
}) })
expect( expect(
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "after" })), yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })),
).toEqual({ ).toEqual({
type: "error", type: "error",
value: value:
@ -321,7 +329,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "same same same")).pipe( return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
Effect.andThen( Effect.andThen(
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
registry.settle(call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })), settleTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
), ),
), ),
Effect.andThen((settled) => Effect.andThen((settled) =>
@ -346,7 +354,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe( return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
Effect.andThen( Effect.andThen(
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
registry.execute(call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })), executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
), ),
), ),
Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))), Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))),
@ -367,7 +375,7 @@ describe("EditTool", () => {
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen( Effect.andThen(
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
registry.execute(call({ path: "concurrent.txt", oldString: "before", newString: "after" })), executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
), ),
), ),
Effect.andThen((result) => Effect.andThen((result) =>
@ -390,7 +398,7 @@ describe("EditTool", () => {
test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => { test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n") const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise( const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()), withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
) )
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> } const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }

View file

@ -8,6 +8,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { GlobTool } from "@opencode-ai/core/tool/glob" import { GlobTool } from "@opencode-ai/core/tool/glob"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_glob_tool_test") const sessionID = SessionV2.ID.make("ses_glob_tool_test")
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
@ -90,8 +91,9 @@ const reset = () => {
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false }) result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
} }
const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({ const call = (input: typeof GlobTool.Input.Type, id = "call-glob") => ({
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "glob", input }, call: { type: "tool-call" as const, id, name: "glob", input },
}) })
@ -99,7 +101,7 @@ describe("GlobTool", () => {
it.effect("registers the glob definition", () => it.effect("registers the glob definition", () =>
Effect.gen(function* () { Effect.gen(function* () {
reset() reset()
expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"]) expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"])
}), }),
) )
@ -108,11 +110,13 @@ describe("GlobTool", () => {
reset() reset()
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({ expect(
yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })),
).toEqual({
type: "text", type: "text",
value: "No files found", value: "No files found",
}) })
expect(assertions).toEqual([ expect(assertions).toMatchObject([
{ {
sessionID, sessionID,
action: "glob", action: "glob",
@ -131,7 +135,7 @@ describe("GlobTool", () => {
reset() reset()
allow = false allow = false
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({ expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({
type: "error", type: "error",
value: "Unable to find files matching *.secret", value: "Unable to find files matching *.secret",
}) })
@ -155,7 +159,7 @@ describe("GlobTool", () => {
partial: false, partial: false,
}) })
expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({ expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({
result: { type: "text", value: "src/index.ts" }, result: { type: "text", value: "src/index.ts" },
output: { output: {
structured: result, structured: result,
@ -181,11 +185,11 @@ describe("GlobTool", () => {
partial: false, partial: false,
}) })
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({ expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
type: "text", type: "text",
value: "docs:guide.md", value: "docs:guide.md",
}) })
expect(assertions).toEqual([ expect(assertions).toMatchObject([
{ {
sessionID, sessionID,
action: "glob", action: "glob",

View file

@ -1,7 +1,7 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Exit, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
@ -19,6 +19,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { it as runtimeIt } from "./lib/effect" import { it as runtimeIt } from "./lib/effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
const searches: LocationSearch.GrepInput[] = [] const searches: LocationSearch.GrepInput[] = []
@ -90,12 +91,20 @@ const sessionID = SessionV2.ID.make("ses_grep_tool_test")
const execute = (input: Record<string, unknown>) => const execute = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) => ToolRegistry.Service.use((registry) =>
registry.execute({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }), executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-grep", name: "grep", input },
}),
) )
const settle = (input: Record<string, unknown>) => const settle = (input: Record<string, unknown>) =>
ToolRegistry.Service.use((registry) => ToolRegistry.Service.use((registry) =>
registry.settle({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }), settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-grep", name: "grep", input },
}),
) )
const reset = () => { const reset = () => {
@ -142,10 +151,10 @@ function provideLive(directory: string, projectReferences = references({})) {
} }
describe("GrepTool", () => { describe("GrepTool", () => {
it.effect("registers the grep contribution", () => it.effect("registers grep", () =>
Effect.gen(function* () { Effect.gen(function* () {
reset() reset()
expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }]) expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }])
}), }),
) )
@ -155,7 +164,7 @@ describe("GrepTool", () => {
const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 } const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" }) expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
expect(assertions).toEqual([ expect(assertions).toMatchObject([
{ {
sessionID, sessionID,
action: "grep", action: "grep",
@ -226,7 +235,7 @@ describe("GrepTool", () => {
}), }),
) )
it.effect("returns a useful tool error for an invalid regex", () => it.effect("preserves an unexpected search defect", () =>
Effect.gen(function* () { Effect.gen(function* () {
reset() reset()
searchFailure = new Ripgrep.InvalidPatternError({ searchFailure = new Ripgrep.InvalidPatternError({
@ -234,10 +243,7 @@ describe("GrepTool", () => {
message: "regex parse error: unclosed character class", message: "regex parse error: unclosed character class",
}) })
expect(yield* execute({ pattern: "[" })).toEqual({ expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true)
type: "error",
value: 'Invalid grep pattern "[": regex parse error: unclosed character class',
})
expect(searches).toEqual([{ pattern: "[" }]) expect(searches).toEqual([{ pattern: "[" }])
}), }),
) )

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer } from "effect" import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
@ -43,35 +43,7 @@ const withStore = <A, E, R>(
const it = testEffect(Layer.empty) const it = testEffect(Layer.empty)
describe("ToolOutputStore", () => { describe("ToolOutputStore", () => {
it.live("returns under-limit text unchanged without writing a file", () => it.live("bounds the provider-facing text channel with one managed file", () =>
withStore(({ store }) =>
Effect.gen(function* () {
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "one\ntwo" })).toEqual({
content: "one\ntwo",
truncated: false,
})
}),
),
)
it.live("stores full output at an absolute managed path", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const content = "HEAD-" + "x".repeat(500) + "-TAIL"
const result = yield* store.truncate({ sessionID, toolCallID: "call-large", content, maxBytes: 300 })
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
expect(path.isAbsolute(result.outputPath)).toBe(true)
expect(result.outputPath).toStartWith(path.join(root, "tool-output", "tool_"))
expect(result.content).toContain(result.outputPath)
expect(result.content).toContain("HEAD-")
expect(result.content).toContain("-TAIL")
expect(yield* fs.readFileString(result.outputPath)).toBe(content)
}),
),
)
it.live("bounds aggregate text blocks with one managed file", () =>
withStore(({ store, fs }) => withStore(({ store, fs }) =>
Effect.gen(function* () { Effect.gen(function* () {
const first = "HEAD-" + "x".repeat(30_000) const first = "HEAD-" + "x".repeat(30_000)
@ -89,7 +61,7 @@ describe("ToolOutputStore", () => {
}) })
expect(result.output.structured).toEqual({ kind: "report" }) expect(result.output.structured).toEqual({ kind: "report" })
expect(result.outputPaths).toHaveLength(1) expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${second}`) expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second)
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview") if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES) expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
}), }),
@ -101,37 +73,151 @@ describe("ToolOutputStore", () => {
Effect.gen(function* () { Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) } const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } }) const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toBe(structured) expect(result.output.structured).toEqual(structured)
expect(result.outputPaths).toHaveLength(1) expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(JSON.stringify(structured)) expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
expect(result.output.content).toHaveLength(1)
}), }),
), ),
) )
it.live("degrades to lossy bounded output when writing fails", () => it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const data = "a".repeat(6 * 1024 * 1024)
const result = yield* store.bound({
sessionID,
toolCallID: "call-file",
output: {
structured: { caption: "pixel" },
content: [{ type: "file", source: { type: "data", data }, mime: "image/png", name: "pixel.png" }],
},
})
expect(result.outputPaths).toEqual([])
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content).toHaveLength(1)
expect(result.output.content[0]).toEqual({
type: "file",
source: { type: "data", data },
mime: "image/png",
name: "pixel.png",
})
}),
),
)
it.live("preserves structured metadata and native media when bounding text", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
const media = {
type: "file" as const,
source: { type: "data" as const, data: "aGVsbG8=" },
mime: "image/png",
name: "pixel.png",
}
const result = yield* store.bound({
sessionID,
toolCallID: "call-text-and-media",
output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
})
expect(result.output.structured).toEqual({ caption: "pixel" })
expect(result.output.content[1]).toEqual(media)
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text)
}),
),
)
it.live("does not double-count structured data duplicated in projected text", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const text = "x".repeat(30_000)
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
expect(yield* store.bound({ sessionID, toolCallID: "call-duplicated", output })).toEqual({
output,
outputPaths: [],
})
}),
),
)
it.live("fails oversized settlement when complete retention cannot be written", () =>
withStore(({ root, store, fs }) => withStore(({ root, store, fs }) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory") yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
const result = yield* store.bound({ const exit = yield* store
sessionID, .bound({
toolCallID: "call-lossy", sessionID,
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] }, toolCallID: "call-lossy",
}) output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
expect(result.outputPaths).toEqual([]) })
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview") .pipe(Effect.exit)
expect(result.output.content[0].text).toContain("could not be retained") expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError")
}), }),
), ),
) )
it.live("does not encode ignored structured metadata when projected content exists", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
expect(yield* store.bound({ sessionID, toolCallID: "call-unencodable", output })).toEqual({
output,
outputPaths: [],
})
}),
),
)
it.live("preserves interruption while retaining complete output", () =>
Effect.gen(function* () {
const root = yield* Effect.promise(() => tmpdir())
const blockedFilesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
ensureDir: () => Effect.void,
writeFileString: () => Effect.never,
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
const store = ToolOutputStore.layer.pipe(
Layer.provide(blockedFilesystem),
Layer.provide(Global.layerWith({ data: root.path })),
)
const exit = yield* Effect.gen(function* () {
const service = yield* ToolOutputStore.Service
const fiber = yield* service
.bound({
sessionID,
toolCallID: "call-interrupted",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
.pipe(Effect.forkChild)
yield* Fiber.interrupt(fiber)
return yield* Fiber.await(fiber)
}).pipe(Effect.provide(store))
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true)
yield* Effect.promise(() => root[Symbol.asyncDispose]())
}),
)
it.live("honors configured limits", () => it.live("honors configured limits", () =>
withStore( withStore(
({ store }) => ({ store }) =>
Effect.gen(function* () { Effect.gen(function* () {
expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 }) expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
expect( const result = yield* store.bound({
(yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated, sessionID,
).toBe(true) toolCallID: "call-config",
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
})
expect(result.outputPaths).toHaveLength(1)
}), }),
new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }), new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
), ),
@ -140,9 +226,12 @@ describe("ToolOutputStore", () => {
it.live("cleans expired managed files and preserves unrelated files", () => it.live("cleans expired managed files and preserves unrelated files", () =>
withStore(({ root, store, fs }) => withStore(({ root, store, fs }) =>
Effect.gen(function* () { Effect.gen(function* () {
const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" }) const old = path.join(root, "tool-output", "tool_old")
const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "recent" }) const recent = path.join(root, "tool-output", "tool_recent")
const unrelated = path.join(root, "tool-output", "keep.txt") const unrelated = path.join(root, "tool-output", "keep.txt")
yield* fs.ensureDir(path.join(root, "tool-output"))
yield* fs.writeFileString(old, "old")
yield* fs.writeFileString(recent, "recent")
yield* fs.writeFileString(unrelated, "keep") yield* fs.writeFileString(unrelated, "keep")
const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000) const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
yield* fs.utimes(old, expired, expired) yield* fs.utimes(old, expired, expired)

View file

@ -6,6 +6,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question" import { QuestionTool } from "@opencode-ai/core/tool/question"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_question_tool_test") const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
@ -40,7 +41,7 @@ const question = Layer.succeed(
list: () => Effect.die("unused"), list: () => Effect.die("unused"),
}), }),
) )
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(question)) const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question))
const it = testEffect(Layer.mergeAll(permission, registry, question, tool)) const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
describe("QuestionTool", () => { describe("QuestionTool", () => {
@ -50,10 +51,11 @@ describe("QuestionTool", () => {
deny = true deny = true
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).toEqual([]) expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
expect( expect(
yield* registry.settle({ yield* settleTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } }, call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
}), }),
).toEqual({ result: { type: "error", value: "Permission denied: question" } }) ).toEqual({ result: { type: "error", value: "Permission denied: question" } })
@ -82,10 +84,11 @@ describe("QuestionTool", () => {
}, },
] ]
expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["question"]) expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
expect( expect(
yield* registry.settle({ yield* settleTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions } }, call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
}), }),
).toEqual({ ).toEqual({
@ -104,8 +107,12 @@ describe("QuestionTool", () => {
], ],
}, },
}) })
expect(assertions).toEqual([{ sessionID, action: "question", resources: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined }) expect(capturedInput()).toEqual({
sessionID,
questions,
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
})
}), }),
) )
@ -116,11 +123,16 @@ describe("QuestionTool", () => {
deny = false deny = false
const registryService = yield* ToolRegistry.Service const registryService = yield* ToolRegistry.Service
yield* registryService.execute({ yield* executeTool(registryService, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } }, call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
}) })
expect(capturedInput()).toEqual({ sessionID, questions: [], tool: undefined }) expect(capturedInput()).toEqual({
sessionID,
questions: [],
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
})
}), }),
) )
@ -130,12 +142,11 @@ describe("QuestionTool", () => {
reject = true reject = true
deny = false deny = false
const registryService = yield* ToolRegistry.Service const registryService = yield* ToolRegistry.Service
const fiber = yield* registryService const fiber = yield* executeTool(registryService, {
.execute({ sessionID,
sessionID, ...toolIdentity,
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } }, call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
}) }).pipe(Effect.forkScoped)
.pipe(Effect.forkScoped)
const exit = yield* Fiber.await(fiber) const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)

View file

@ -1,13 +1,15 @@
import { beforeEach, describe, expect } from "bun:test" import { beforeEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Exit, Layer } from "effect"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments" import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { Image } from "@opencode-ai/core/image"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read" import { ReadTool } from "@opencode-ai/core/tool/read"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
const readCalls: { const readCalls: {
@ -74,13 +76,29 @@ const permission = Layer.succeed(
) )
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) })) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
const image = Image.layer.pipe(Layer.provide(config))
const unavailableImage = Layer.succeed(
Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
)
const read = ReadTool.layer.pipe( const read = ReadTool.layer.pipe(
Layer.provide(registry), Layer.provide(registry),
Layer.provide(filesystem), Layer.provide(filesystem),
Layer.provide(permission), Layer.provide(permission),
Layer.provide(config), Layer.provide(config),
Layer.provide(image),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, image, read))
const unavailableRead = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(permission),
Layer.provide(config),
Layer.provide(unavailableImage),
)
const itWithoutResizer = testEffect(
Layer.mergeAll(registry, filesystem, permission, config, unavailableImage, unavailableRead),
) )
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, read))
const sessionID = SessionV2.ID.make("ses_read_tool_test") const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => { describe("ReadTool", () => {
@ -100,11 +118,12 @@ describe("ReadTool", () => {
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect(yield* registry.definitions()).toMatchObject([{ name: "read" }]) expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }])
expect(yield* registry.definitions([{ action: "read", resource: "*", effect: "deny" }])).toEqual([]) expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([])
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}), }),
).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } }) ).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
@ -125,8 +144,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } }, call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
}), }),
).toEqual({ ).toEqual({
@ -138,12 +158,12 @@ describe("ReadTool", () => {
}) })
expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }]) expect(readCalls).toEqual([{ input: { path: "pixel.png" }, page: {} }])
const settled = yield* registry.settle({ const settled = yield* settleTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } }, call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
}) })
expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" }) expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(JSON.stringify(settled.output?.structured)).not.toContain(png)
expect(settled.output?.content).toMatchObject([ expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" }, { type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", source: { type: "data", data: png } }, { type: "file", mime: "image/png", source: { type: "data", data: png } },
@ -151,6 +171,64 @@ describe("ReadTool", () => {
}), }),
) )
it.effect("preserves a PNG above the generic text limit as native media", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const pixels = Uint8Array.from({ length: 256 * 256 * 4 }, (_, index) => (index * 73 + (index >> 3)) % 256)
const source = new photon.PhotonImage(pixels, 256, 256)
const png = Buffer.from(source.get_bytes()).toString("base64")
source.free()
expect(Buffer.byteLength(png)).toBeGreaterThan(50 * 1024)
readResult = new FileSystem.BinaryContent({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } },
})
expect(settled.outputPaths).toBeUndefined()
expect(settled.output?.structured).toMatchObject({ type: "binary", mime: "image/png", encoding: "base64" })
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "large.png" },
],
})
}),
)
itWithoutResizer.effect("returns the original image when the resizer is unavailable", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
readResult = new FileSystem.BinaryContent({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
const registry = yield* ToolRegistry.Service
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } },
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "media", mediaType: "image/png", data: png }],
})
}),
)
it.effect("rejects invalid image data returned by the filesystem", () => it.effect("rejects invalid image data returned by the filesystem", () =>
Effect.gen(function* () { Effect.gen(function* () {
readResult = new FileSystem.BinaryContent({ readResult = new FileSystem.BinaryContent({
@ -162,8 +240,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } }, call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}), }),
).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" }) ).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
@ -193,8 +272,9 @@ describe("ReadTool", () => {
}), }),
] ]
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({ const result = yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } }, call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
}) })
@ -224,8 +304,9 @@ describe("ReadTool", () => {
}), }),
] ]
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({ const result = yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } }, call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
}) })
@ -261,8 +342,9 @@ describe("ReadTool", () => {
}), }),
] ]
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({ const result = yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } }, call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
}) })
@ -283,8 +365,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } }, call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
}), }),
).toMatchObject({ ).toMatchObject({
@ -294,22 +377,25 @@ describe("ReadTool", () => {
}), }),
) )
it.effect("preserves unsupported binary errors from the filesystem", () => it.effect("preserves unexpected filesystem defects", () =>
Effect.gen(function* () { Effect.gen(function* () {
readFailure = new FileSystem.BinaryFileError("archive.dat") readFailure = new FileSystem.BinaryFileError("archive.dat")
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ Exit.isFailure(
sessionID, yield* executeTool(registry, {
call: { sessionID,
type: "tool-call", ...toolIdentity,
id: "call-binary", call: {
name: "read", type: "tool-call",
input: { path: "archive.dat", offset: 2, limit: 1 }, id: "call-binary",
}, name: "read",
}), input: { path: "archive.dat", offset: 2, limit: 1 },
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) },
}).pipe(Effect.exit),
),
).toBe(true)
expect(readCalls).toEqual([ expect(readCalls).toEqual([
{ input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } }, { input: { path: "archive.dat", offset: 2, limit: 1 }, page: { offset: 2, limit: 1 } },
]) ])
@ -322,8 +408,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
}), }),
).toEqual({ type: "error", value: "Unable to read README.md" }) ).toEqual({ type: "error", value: "Unable to read README.md" })
@ -337,8 +424,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { call: {
type: "tool-call", type: "tool-call",
id: "call-read-directory", id: "call-read-directory",
@ -359,8 +447,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } }, call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}), }),
).toEqual({ type: "error", value: "Unable to read src" }) ).toEqual({ type: "error", value: "Unable to read src" })
@ -372,8 +461,9 @@ describe("ReadTool", () => {
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } }, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
}) })
@ -381,17 +471,20 @@ describe("ReadTool", () => {
}), }),
) )
it.effect("settles missing files as typed tool errors", () => it.effect("preserves unexpected resolution defects", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
resolveFailure = new Error("missing") resolveFailure = new Error("missing")
expect( expect(
yield* registry.execute({ Exit.isFailure(
sessionID, yield* executeTool(registry, {
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } }, sessionID,
}), ...toolIdentity,
).toEqual({ type: "error", value: "Unable to read missing.txt" }) call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
}).pipe(Effect.exit),
),
).toBe(true)
expect(readCalls).toEqual([]) expect(readCalls).toEqual([])
}), }),
@ -410,8 +503,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { call: {
type: "tool-call", type: "tool-call",
id: "call-large", id: "call-large",
@ -438,8 +532,9 @@ describe("ReadTool", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } }, call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
}), }),
).toEqual({ type: "error", value: "Cannot read binary file: late-binary" }) ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })

View file

@ -9,10 +9,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SkillV2 } from "@opencode-ai/core/skill" import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillTool } from "@opencode-ai/core/tool/skill" import { SkillTool } from "@opencode-ai/core/tool/skill"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect" import { it } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_skill_tool_test") const sessionID = SessionV2.ID.make("ses_skill_tool_test")
@ -41,9 +41,6 @@ describe("SkillTool", () => {
let current = [info] let current = [info]
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
let deny = false let deny = false
const truncations: ToolOutputStore.TruncateInput[] = []
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
let bootWaited = false let bootWaited = false
const boot = Layer.succeed( const boot = Layer.succeed(
PluginBoot.Service, PluginBoot.Service,
@ -77,75 +74,58 @@ describe("SkillTool", () => {
}), }),
) )
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const tool = SkillTool.layer.pipe( const tool = SkillTool.layer.pipe(
Layer.provide(registry), Layer.provide(registry),
Layer.provide(permission),
Layer.provide(FSUtil.defaultLayer), Layer.provide(FSUtil.defaultLayer),
Layer.provide(boot), Layer.provide(boot),
Layer.provide(skills), Layer.provide(skills),
Layer.provide(resources),
) )
const layer = Layer.mergeAll(permission, skills, registry, boot, resources, tool) const layer = Layer.mergeAll(permission, skills, registry, boot, tool)
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect(bootWaited).toBe(true) expect(bootWaited).toBe(true)
expect((yield* registry.definitions())[0]).toMatchObject({ expect((yield* toolDefinitions(registry))[0]).toMatchObject({
name: "skill", name: "skill",
description: SkillTool.description, description: SkillTool.description,
}) })
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } }, call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } },
}), }),
).toEqual({ ).toEqual({
type: "text", type: "text",
value: SkillTool.toModelOutput(info, [reference]), value: SkillTool.toModelOutput(info, [reference]),
}) })
expect(truncations).toEqual([
{ sessionID, toolCallID: "call-skill", content: SkillTool.toModelOutput(info, [reference]) },
])
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect( expect(
yield* registry.settle({ yield* settleTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } }, call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } },
}), }),
).toMatchObject({ ).toMatchObject({
result: { type: "text", value: expect.stringContaining("/tmp/tool-output/tool_opaque") }, result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) },
output: { output: { structured: { name: "effect" } },
structured: { truncated: true, outputPath: "/tmp/tool-output/tool_opaque" },
},
}) })
expect(assertions).toEqual([ expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, { sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, { sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
]) ])
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } }, call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } },
}), }),
).toEqual({ type: "error", value: "Unable to load skill missing" }) ).toEqual({ type: "error", value: "Unable to load skill missing" })
deny = true deny = true
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } }, call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } },
}), }),
).toEqual({ type: "error", value: "Unable to load skill effect" }) ).toEqual({ type: "error", value: "Unable to load skill effect" })
@ -163,10 +143,10 @@ describe("SkillTool", () => {
]), ]),
) )
current = [flat] current = [flat]
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } }, call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
}), }),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })

View file

@ -12,6 +12,7 @@ import { SessionTodo } from "@opencode-ai/core/session/todo"
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite" import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test") const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
@ -35,7 +36,7 @@ const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database)) const events = EventV2.layer.pipe(Layer.provide(database))
const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events)) const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(todos)) const tool = TodoWriteTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(todos))
const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool)) const it = testEffect(Layer.mergeAll(database, events, todos, permission, registry, tool))
const setup = Effect.gen(function* () { const setup = Effect.gen(function* () {
@ -63,6 +64,7 @@ const setup = Effect.gen(function* () {
const call = (todos: ReadonlyArray<SessionTodo.Info>, id = "call-todowrite") => ({ const call = (todos: ReadonlyArray<SessionTodo.Info>, id = "call-todowrite") => ({
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } }, call: { type: "tool-call" as const, id, name: TodoWriteTool.name, input: { todos } },
}) })
@ -74,15 +76,15 @@ describe("TodoWriteTool", () => {
const service = yield* SessionTodo.Service const service = yield* SessionTodo.Service
const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }] const todoList = [{ content: "Implement slice", status: "in_progress", priority: "high" }]
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual([TodoWriteTool.name]) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([TodoWriteTool.name])
expect(yield* registry.settle(call(todoList))).toEqual({ expect(yield* settleTool(registry, call(todoList))).toEqual({
result: { type: "text", value: JSON.stringify(todoList, null, 2) }, result: { type: "text", value: JSON.stringify(todoList, null, 2) },
output: { output: {
structured: { todos: todoList }, structured: { todos: todoList },
content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }], content: [{ type: "text", text: JSON.stringify(todoList, null, 2) }],
}, },
}) })
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
expect(yield* service.get(sessionID)).toEqual(todoList) expect(yield* service.get(sessionID)).toEqual(todoList)
}), }),
) )
@ -95,12 +97,14 @@ describe("TodoWriteTool", () => {
yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] }) yield* service.update({ sessionID, todos: [{ content: "keep", status: "pending", priority: "low" }] })
deny = true deny = true
expect(yield* registry.execute(call([{ content: "blocked", status: "completed", priority: "high" }]))).toEqual({ expect(
yield* executeTool(registry, call([{ content: "blocked", status: "completed", priority: "high" }])),
).toEqual({
type: "error", type: "error",
value: "Unable to update todos", value: "Unable to update todos",
}) })
expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }]) expect(yield* service.get(sessionID)).toEqual([{ content: "keep", status: "pending", priority: "low" }])
expect(assertions).toEqual([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "todowrite", resources: ["*"], save: ["*"] }])
}), }),
) )
}) })

View file

@ -4,19 +4,16 @@ import * as TestClock from "effect/testing/TestClock"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch" import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_webfetch_test") const sessionID = SessionV2.ID.make("ses_webfetch_test")
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = [] const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
const truncations: ToolOutputStore.TruncateInput[] = []
let respond = (_request: HttpClientRequest.HttpClientRequest) => let respond = (_request: HttpClientRequest.HttpClientRequest) =>
Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } })) Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const http = Layer.succeed( const http = Layer.succeed(
HttpClient.HttpClient, HttpClient.HttpClient,
@ -38,42 +35,31 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), list: () => Effect.die("unused"),
}), }),
) )
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(http), Layer.provide(resources)) const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http))
const it = testEffect(Layer.mergeAll(registry, permission, http, resources, webfetch)) const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch))
const fetchWebfetch = WebFetchTool.layer.pipe( const fetchWebfetch = WebFetchTool.layer.pipe(
Layer.provide(registry), Layer.provide(registry),
Layer.provide(permission),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(resources),
) )
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, resources, fetchWebfetch)) const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch))
const reset = () => { const reset = () => {
requests.length = 0 requests.length = 0
assertions.length = 0 assertions.length = 0
truncations.length = 0
respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } })) respond = () => Effect.succeed(new Response("hello", { headers: { "content-type": "text/plain" } }))
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
} }
const call = (input: typeof WebFetchTool.Parameters.Type, id = "call-webfetch") => ({ const call = (input: typeof WebFetchTool.Input.Type, id = "call-webfetch") => ({
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "webfetch", input }, call: { type: "tool-call" as const, id, name: "webfetch", input },
}) })
describe("WebFetchTool helpers", () => { describe("WebFetchTool helpers", () => {
test("defaults format and rejects invalid timeout controls", () => { test("defaults format and rejects invalid timeout controls", () => {
const decode = Schema.decodeUnknownSync(WebFetchTool.Parameters) const decode = Schema.decodeUnknownSync(WebFetchTool.Input)
expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" }) expect(decode({ url: "https://example.com" })).toEqual({ url: "https://example.com", format: "markdown" })
expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow() expect(() => decode({ url: "https://example.com", timeout: 0 })).toThrow()
expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow() expect(() => decode({ url: "https://example.com", timeout: WebFetchTool.MAX_TIMEOUT_SECONDS + 1 })).toThrow()
@ -86,22 +72,22 @@ describe("WebFetchTool helpers", () => {
}) })
}) })
describe("WebFetchTool contribution", () => { describe("WebFetchTool registration", () => {
it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () => it.effect("registers and fetches an ordinary hostname HTTP URL without rewriting it", () =>
Effect.gen(function* () { Effect.gen(function* () {
reset() reset()
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const url = "http://example.com/public" const url = "http://example.com/public"
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["webfetch"]) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* registry.settle(call({ url, format: "text", timeout: 4 }))).toEqual({ expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
result: { type: "text", value: "hello" }, result: { type: "text", value: "hello" },
output: { output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello", truncated: false }, structured: { url, contentType: "text/plain", format: "text", output: "hello" },
content: [{ type: "text", text: "hello" }], content: [{ type: "text", text: "hello" }],
}, },
}) })
expect(assertions).toEqual([ expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } }, { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
]) ])
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }]) expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
@ -114,11 +100,11 @@ describe("WebFetchTool contribution", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const url = "http://localhost/private" const url = "http://localhost/private"
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
type: "text", type: "text",
value: "hello", value: "hello",
}) })
expect(assertions).toEqual([ expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } }, { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
]) ])
expect(requests.map((request) => request.url)).toEqual([url]) expect(requests.map((request) => request.url)).toEqual([url])
@ -142,8 +128,11 @@ describe("WebFetchTool contribution", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const url = new URL("/redirect", server.url).toString() const url = new URL("/redirect", server.url).toString()
expect(yield* registry.execute(call({ url, format: "text" }))).toEqual({ type: "text", value: "redirected" }) expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({
expect(assertions).toEqual([ type: "text",
value: "redirected",
})
expect(assertions).toMatchObject([
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } }, { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
]) ])
}), }),
@ -156,7 +145,7 @@ describe("WebFetchTool contribution", () => {
reset() reset()
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "file:///etc/passwd", format: "text" }))).toEqual({ expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
type: "error", type: "error",
value: "Unable to fetch file:///etc/passwd", value: "Unable to fetch file:///etc/passwd",
}) })
@ -176,41 +165,17 @@ describe("WebFetchTool contribution", () => {
) )
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({ expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({
type: "text", type: "text",
value: "# Hello\n\nworld", value: "# Hello\n\nworld",
}) })
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text", type: "text",
value: "Helloworld", value: "Helloworld",
}) })
}), }),
) )
it.effect("exposes managed overflow through a path", () =>
Effect.gen(function* () {
reset()
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle(call({ url: "https://1.1.1.1", format: "html" }, "call-overflow"))
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
})
expect(settled.output?.structured).toMatchObject({
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "hello", mime: "text/html" }])
}),
)
it.effect("rejects declared and streamed oversized bodies", () => it.effect("rejects declared and streamed oversized bodies", () =>
Effect.gen(function* () { Effect.gen(function* () {
reset() reset()
@ -221,7 +186,7 @@ describe("WebFetchTool contribution", () => {
headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) }, headers: { "content-type": "text/plain", "content-length": String(WebFetchTool.MAX_RESPONSE_BYTES + 1) },
}), }),
) )
expect(yield* registry.execute(call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({ expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({
type: "error", type: "error",
value: "Unable to fetch https://1.1.1.1/declared", value: "Unable to fetch https://1.1.1.1/declared",
}) })
@ -230,7 +195,7 @@ describe("WebFetchTool contribution", () => {
Effect.succeed( Effect.succeed(
new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }), new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }),
) )
expect(yield* registry.execute(call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({ expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({
type: "error", type: "error",
value: "Unable to fetch https://1.1.1.1/streamed", value: "Unable to fetch https://1.1.1.1/streamed",
}) })
@ -242,17 +207,16 @@ describe("WebFetchTool contribution", () => {
reset() reset()
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } })) respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } }))
expect(yield* registry.execute(call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({ expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({
type: "error", type: "error",
value: "Unable to fetch https://1.1.1.1/image", value: "Unable to fetch https://1.1.1.1/image",
}) })
respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } })) respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } }))
expect(yield* registry.execute(call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({ expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({
type: "error", type: "error",
value: "Unable to fetch https://1.1.1.1/file", value: "Unable to fetch https://1.1.1.1/file",
}) })
expect(truncations).toEqual([])
}), }),
) )
@ -268,7 +232,7 @@ describe("WebFetchTool contribution", () => {
) )
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect(yield* registry.execute(call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({
type: "text", type: "text",
value: "ok", value: "ok",
}) })
@ -283,9 +247,10 @@ describe("WebFetchTool contribution", () => {
reset() reset()
respond = () => Effect.never respond = () => Effect.never
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const fiber = yield* registry const fiber = yield* executeTool(
.execute(call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 })) registry,
.pipe(Effect.forkChild) call({ url: "https://1.1.1.1/slow", format: "text", timeout: 1 }),
).pipe(Effect.forkChild)
yield* TestClock.adjust(Duration.seconds(1)) yield* TestClock.adjust(Duration.seconds(1))
expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" }) expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" })

View file

@ -5,8 +5,8 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch" import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_websearch_test") const sessionID = SessionV2.ID.make("ses_websearch_test")
const payload = (text: string) => const payload = (text: string) =>
@ -18,7 +18,7 @@ const payload = (text: string) =>
describe("WebSearchTool provider selection", () => { describe("WebSearchTool provider selection", () => {
test("rejects out-of-range numeric controls", () => { test("rejects out-of-range numeric controls", () => {
const decode = Schema.decodeUnknownSync(WebSearchTool.Parameters) const decode = Schema.decodeUnknownSync(WebSearchTool.Input)
expect(() => decode({ query: "x", numResults: 0 })).toThrow() expect(() => decode({ query: "x", numResults: 0 })).toThrow()
expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow() expect(() => decode({ query: "x", numResults: WebSearchTool.MAX_NUM_RESULTS + 1 })).toThrow()
expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow() expect(() => decode({ query: "x", contextMaxCharacters: WebSearchTool.MAX_CONTEXT_CHARACTERS + 1 })).toThrow()
@ -65,11 +65,8 @@ interface Request {
const requests: Request[] = [] const requests: Request[] = []
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
const truncations: ToolOutputStore.TruncateInput[] = []
let responseBody = payload("search results") let responseBody = payload("search results")
let config: WebSearchTool.Config = { enableExa: false, enableParallel: false } let config: WebSearchTool.Config = { enableExa: false, enableParallel: false }
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const http = Layer.succeed( const http = Layer.succeed(
HttpClient.HttpClient, HttpClient.HttpClient,
@ -117,40 +114,28 @@ const websearchConfig = Layer.succeed(
}, },
}), }),
) )
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const websearch = WebSearchTool.layer.pipe( const websearch = WebSearchTool.layer.pipe(
Layer.provide(registry), Layer.provide(registry),
Layer.provide(permission), Layer.provide(permission),
Layer.provide(http), Layer.provide(http),
Layer.provide(websearchConfig), Layer.provide(websearchConfig),
Layer.provide(resources),
) )
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, resources, websearch)) const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch))
describe("WebSearchTool contribution", () => { describe("WebSearchTool registration", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () => it.effect("registers websearch, asserts query permission, and calls Exa", () =>
Effect.gen(function* () { Effect.gen(function* () {
requests.length = 0 requests.length = 0
assertions.length = 0 assertions.length = 0
truncations.length = 0
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
responseBody = payload("exa results") responseBody = payload("exa results")
config = { provider: "exa", enableExa: false, enableParallel: false } config = { provider: "exa", enableExa: false, enableParallel: false }
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["websearch"]) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch"])
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { call: {
type: "tool-call", type: "tool-call",
id: "call-exa", id: "call-exa",
@ -165,7 +150,7 @@ describe("WebSearchTool contribution", () => {
}, },
}), }),
).toEqual({ type: "text", value: "exa results" }) ).toEqual({ type: "text", value: "exa results" })
expect(assertions).toEqual([ expect(assertions).toMatchObject([
{ {
sessionID, sessionID,
action: "websearch", action: "websearch",
@ -213,8 +198,9 @@ describe("WebSearchTool contribution", () => {
config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" } config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" }
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({ const settled = yield* settleTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } },
}) })
@ -235,7 +221,7 @@ describe("WebSearchTool contribution", () => {
expect(settled).toEqual({ expect(settled).toEqual({
result: { type: "text", value: "parallel results" }, result: { type: "text", value: "parallel results" },
output: { output: {
structured: { provider: "parallel", text: "parallel results", truncated: false }, structured: { provider: "parallel", text: "parallel results" },
content: [{ type: "text", text: "parallel results" }], content: [{ type: "text", text: "parallel results" }],
}, },
}) })
@ -251,8 +237,9 @@ describe("WebSearchTool contribution", () => {
config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" } config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" }
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({ const settled = yield* settleTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } }, call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } },
}) })
@ -270,47 +257,15 @@ describe("WebSearchTool contribution", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } }, call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } },
}), }),
).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS }) ).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS })
}), }),
) )
it.effect("exposes managed overflow through typed structured output", () =>
Effect.gen(function* () {
requests.length = 0
assertions.length = 0
truncations.length = 0
responseBody = payload("full search results")
config = { provider: "exa", enableExa: false, enableParallel: false }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
const registry = yield* ToolRegistry.Service
const settled = yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-overflow", name: "websearch", input: { query: "verbose" } },
})
expect(settled.result).toMatchObject({
type: "text",
value: expect.stringContaining("/tmp/tool-output/tool_opaque"),
})
expect(settled.output?.structured).toMatchObject({
provider: "exa",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
expect(truncations).toEqual([{ sessionID, toolCallID: "call-overflow", content: "full search results" }])
}),
)
it.effect("rejects oversized MCP response bodies", () => it.effect("rejects oversized MCP response bodies", () =>
Effect.gen(function* () { Effect.gen(function* () {
requests.length = 0 requests.length = 0
@ -320,8 +275,9 @@ describe("WebSearchTool contribution", () => {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
expect( expect(
yield* registry.execute({ yield* executeTool(registry, {
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } },
}), }),
).toEqual({ type: "error", value: "Unable to search the web for too much" }) ).toEqual({ type: "error", value: "Unable to search the web for too much" })

View file

@ -15,6 +15,7 @@ import { WriteTool } from "@opencode-ai/core/tool/write"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_write_tool_test") const sessionID = SessionV2.ID.make("ses_write_tool_test")
const assertions: PermissionV2.AssertInput[] = [] const assertions: PermissionV2.AssertInput[] = []
@ -64,14 +65,20 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const write = WriteTool.layer.pipe(Layer.provide(registry), Layer.provide(resolution), Layer.provide(mutation)) const write = WriteTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(resolution),
Layer.provide(mutation),
)
return Effect.gen(function* () { return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service) return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write))) }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
} }
const call = (input: typeof WriteTool.Parameters.Type, id = "call-write") => ({ const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
sessionID, sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "write", input }, call: { type: "tool-call" as const, id, name: "write", input },
}) })
@ -85,8 +92,8 @@ describe("WriteTool", () => {
reset() reset()
return withTool(tmp.path, (registry) => return withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["write"]) expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* registry.settle(call({ path: "src/new.txt", content: "created" })) const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({ expect(settled).toEqual({
result: { type: "text", value: "Created file successfully: src/new.txt" }, result: { type: "text", value: "Created file successfully: src/new.txt" },
output: { output: {
@ -102,7 +109,7 @@ describe("WriteTool", () => {
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe( expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created", "created",
) )
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")]) expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
}), }),
) )
@ -118,7 +125,7 @@ describe("WriteTool", () => {
reset() reset()
return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe( return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe(
Effect.andThen( Effect.andThen(
withTool(tmp.path, (registry) => registry.settle(call({ path: "existing.txt", content: "after" }))), withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))),
), ),
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -149,8 +156,11 @@ describe("WriteTool", () => {
Effect.andThen( Effect.andThen(
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* registry.settle(call({ path: "preserved.txt", content: "after" }, "call-preserved")) yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
yield* registry.settle(call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated")) yield* settleTool(
registry,
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
)
expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter") expect(yield* Effect.promise(() => fs.readFile(preserved, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter") expect(yield* Effect.promise(() => fs.readFile(deduplicated, "utf8"))).toBe("\uFEFFafter")
@ -169,7 +179,7 @@ describe("WriteTool", () => {
(tmp) => { (tmp) => {
reset() reset()
const target = path.join(tmp.path, "absolute.txt") const target = path.join(tmp.path, "absolute.txt")
return withTool(tmp.path, (registry) => registry.execute(call({ path: target, content: "inside" }))).pipe( return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe(
Effect.andThen((result) => Effect.andThen((result) =>
Effect.gen(function* () { Effect.gen(function* () {
expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" }) expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" })
@ -189,7 +199,9 @@ describe("WriteTool", () => {
([active, outside]) => { ([active, outside]) => {
reset() reset()
const target = path.join(outside.path, "external.txt") const target = path.join(outside.path, "external.txt")
return withTool(active.path, (registry) => registry.settle(call({ path: target, content: "external" }))).pipe( return withTool(active.path, (registry) =>
settleTool(registry, call({ path: target, content: "external" })),
).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.gen(function* () { Effect.gen(function* () {
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt") const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
@ -227,7 +239,9 @@ describe("WriteTool", () => {
reset() reset()
denyAction = "external_directory" denyAction = "external_directory"
expect( expect(
yield* withTool(active.path, (registry) => registry.execute(call({ path: external, content: "blocked" }))), yield* withTool(active.path, (registry) =>
executeTool(registry, call({ path: external, content: "blocked" })),
),
).toEqual({ ).toEqual({
type: "error", type: "error",
value: `Unable to write ${external}`, value: `Unable to write ${external}`,
@ -239,7 +253,7 @@ describe("WriteTool", () => {
denyAction = "edit" denyAction = "edit"
expect( expect(
yield* withTool(active.path, (registry) => yield* withTool(active.path, (registry) =>
registry.execute(call({ path: "denied.txt", content: "blocked" })), executeTool(registry, call({ path: "denied.txt", content: "blocked" })),
), ),
).toEqual({ ).toEqual({
type: "error", type: "error",
@ -259,7 +273,7 @@ describe("WriteTool", () => {
test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => { test("keeps the locked write schema, semantics docstring, and deferred UX TODOs visible", async () => {
const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n") const source = (await fs.readFile(new URL("../src/tool/write.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
const definition = await Effect.runPromise( const definition = await Effect.runPromise(
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()), withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => toolDefinitions(registry)),
) )
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> } const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }

View file

@ -1,26 +0,0 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { fileURLToPath } from "node:url"
import { pack } from "./pack.js"
import { verifyPackage } from "./verify-package.js"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
const published = async (name: string, version: string) =>
(await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- package.json is validated by the package schema and build checks.
const pkg = JSON.parse(await Bun.file("package.json").text()) as { readonly name: string; readonly version: string }
if (await published(pkg.name, pkg.version)) {
console.log(`already published ${pkg.name}@${pkg.version}`)
} else {
const archive = await pack()
try {
await verifyPackage(archive)
await $`npm publish ${archive} --tag beta --access public --provenance`
} finally {
await Bun.file(archive).delete()
}
}

View file

@ -0,0 +1,115 @@
import { Effect } from "effect"
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
import { AppRuntime } from "@/effect/app-runtime"
import { Search } from "@opencode-ai/core/filesystem/search"
import { InstanceStore } from "@/project/instance-store"
const dir = process.cwd()
const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"]
const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"]
const GLOB_QUERIES = ["**/*.test.ts"]
const FILE_LIMIT = 100
const GREP_LIMIT = 50
const GLOB_LIMIT = 50
const run = <A>(effect: Effect.Effect<A, unknown, Search.Service>) =>
AppRuntime.runPromise(
InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)),
) as Promise<A>
// --- raw Fff picker ---
const t0 = performance.now()
const made = Fff.create({ basePath: dir, aiMode: true })
if (!made.ok) {
console.error("Fff.create failed:", made.error)
process.exit(1)
}
const picker = made.value
console.log(`picker create: ${(performance.now() - t0).toFixed(1)}ms`)
const tw = performance.now()
await picker.waitForScan(2_500)
console.log(`wait for scan: ${(performance.now() - tw).toFixed(1)}ms`)
// warmup grep to let the content index build
const tWarmup = performance.now()
picker.grep("_warmup_", { mode: "regex", maxMatchesPerFile: 1, timeBudgetMs: 1_500 })
console.log(`grep warmup: ${(performance.now() - tWarmup).toFixed(1)}ms`)
console.log()
console.log("--- raw picker (warm) ---")
for (const q of FILE_QUERIES) {
const t = performance.now()
const r = picker.fileSearch(q, { pageSize: Math.max(FILE_LIMIT, 100) })
const count = r.ok ? r.value.items.length : "err"
console.log(`[picker] fileSearch "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} results)`)
}
for (const q of GREP_QUERIES) {
const t = performance.now()
const r = picker.grep(q, { mode: "regex", pageSize: GREP_LIMIT, timeBudgetMs: 1_500 })
const count = r.ok ? r.value.items.length : "err"
console.log(`[picker] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} matches)`)
}
picker.destroy()
// --- Ripgrep service (via Search with file:["."] to force rg path) ---
console.log()
console.log("--- Ripgrep (via Search service) ---")
// warmup
await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_rg_", limit: 1, file: ["."] })))
for (const q of GREP_QUERIES) {
const t = performance.now()
const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT, file: ["."] })))
console.log(
`[ripgrep] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} total, limit is per-file not total)`,
)
}
// --- Search service: init breakdown ---
console.log()
// 1) runtime + InstanceState + picker create + scan poll
const tRuntime = performance.now()
await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: "_warmup_file_", limit: 1 })))
console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`)
// 2) grep warmup (content index cold-start inside the Search service picker)
const tGrepWarmup = performance.now()
await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_grep_", limit: 1 })))
console.log(`[Search] init grep (content index warmup): ${(performance.now() - tGrepWarmup).toFixed(1)}ms`)
console.log()
console.log("--- Search service (warm) ---")
for (const q of FILE_QUERIES) {
const t = performance.now()
const r = await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: q, limit: FILE_LIMIT })))
console.log(
`[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r?.length ?? "undefined (cache fallback)"} results)`,
)
}
for (const q of GREP_QUERIES) {
const t = performance.now()
const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT })))
console.log(
`[Search.search] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} matches, engine=${r.engine})`,
)
}
for (const q of GLOB_QUERIES) {
const t = performance.now()
const r = await run(Search.Service.use((svc) => svc.glob({ cwd: dir, pattern: q, limit: GLOB_LIMIT })))
console.log(
`[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.files.length} files, truncated=${r.truncated})`,
)
}
process.exit(0)

View file

@ -2,7 +2,7 @@ import { EOL } from "os"
import { Effect } from "effect" import { Effect } from "effect"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { effectCmd } from "../../effect-cmd" import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd" import { cmd } from "../cmd"
@ -68,7 +68,7 @@ const FileTreeCommand = effectCmd({
default: process.cwd(), default: process.cwd(),
}), }),
handler: Effect.fn("Cli.debug.file.tree")(function* (args) { handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
console.log(JSON.stringify(tree, null, 2)) console.log(JSON.stringify(tree, null, 2))
}), }),
}) })

View file

@ -1,6 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { effectCmd } from "../../effect-cmd" import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd" import { cmd } from "../cmd"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
@ -22,7 +22,7 @@ const TreeCommand = effectCmd({
handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
const ctx = yield* InstanceRef const ctx = yield* InstanceRef
if (!ctx) return if (!ctx) return
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
process.stdout.write(tree + EOL) process.stdout.write(tree + EOL)
}), }),
}) })
@ -47,8 +47,8 @@ const FilesCommand = effectCmd({
handler: Effect.fn("Cli.debug.rg.files")(function* (args) { handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
const ctx = yield* InstanceRef const ctx = yield* InstanceRef
if (!ctx) return if (!ctx) return
const rg = yield* Ripgrep.Service const search = yield* Search.Service
const files = yield* rg const files = yield* search
.files({ .files({
cwd: ctx.directory, cwd: ctx.directory,
glob: args.glob ? [args.glob] : undefined, glob: args.glob ? [args.glob] : undefined,
@ -85,7 +85,7 @@ const SearchCommand = effectCmd({
const ctx = yield* InstanceRef const ctx = yield* InstanceRef
if (!ctx) return if (!ctx) return
const results = yield* Effect.orDie( const results = yield* Effect.orDie(
Ripgrep.Service.use((svc) => Search.Service.use((svc) =>
svc.search({ svc.search({
cwd: ctx.directory, cwd: ctx.directory,
pattern: args.pattern, pattern: args.pattern,

View file

@ -345,21 +345,12 @@ export function Autocomplete(props: {
const options: AutocompleteOption[] = [] const options: AutocompleteOption[] = []
// Add file options // Add file options. Trust the order returned by fff (frecency, fuzzy
// score, filename bonus, etc. are already factored in).
if (!result.error && result.data) { if (!result.error && result.data) {
const sortedFiles = result.data.sort((a, b) => {
const aScore = frecency.getFrecency(a)
const bScore = frecency.getFrecency(b)
if (aScore !== bScore) return bScore - aScore
const aDepth = a.split("/").length
const bDepth = b.split("/").length
if (aDepth !== bDepth) return aDepth - bDepth
return a.localeCompare(b)
})
const width = props.anchor().width - 4 const width = props.anchor().width - 4
options.push( options.push(
...sortedFiles.map((item): AutocompleteOption => { ...result.data.map((item): AutocompleteOption => {
const { filename, url, part } = createFilePart(item, lineRange) const { filename, url, part } = createFilePart(item, lineRange)
const isDir = item.endsWith("/") const isDir = item.endsWith("/")
@ -506,45 +497,49 @@ export function Autocomplete(props: {
const agentsValue = agents() const agentsValue = agents()
const referenceAliasesValue = referenceAliases() const referenceAliasesValue = referenceAliases()
const commandsValue = commands() const commandsValue = commands()
const mixed: AutocompleteOption[] =
store.visible === "@"
? referenceMatchValue
? referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
: [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()]
: [...commandsValue]
const searchValue = search() const searchValue = search()
// @<alias>/... — narrow to the matched reference, files come from fff
// already ranked so there is no re-ranking here.
if (store.visible === "@" && referenceMatchValue) {
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
}
// Files come from fff already fuzzy ranked and filtered
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
if (!searchValue) { if (!searchValue) {
return mixed return [...nonFileOptions, ...fileOptions]
} }
if (files.loading && prev && prev.length > 0) { if (files.loading && prev && prev.length > 0) {
return prev return prev
} }
if (referenceMatchValue) return mixed const fuzziedNonFiles = fuzzysort
.go(removeLineRange(searchValue), nonFileOptions, {
keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description",
(obj) => obj.aliases?.join(" ") ?? "",
],
limit: 10,
scoreFn: (objResults) => {
const displayResult = objResults[0]
let score = objResults.score
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
score *= 2
}
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
return score * (1 + frecencyScore)
},
})
.map((arr) => arr.obj)
const result = fuzzysort.go(removeLineRange(searchValue), mixed, { return [...fuzziedNonFiles, ...fileOptions].slice(0, 10)
keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description",
(obj) => obj.aliases?.join(" ") ?? "",
],
limit: 10,
scoreFn: (objResults) => {
const displayResult = objResults[0]
let score = objResults.score
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
score *= 2
}
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
return score * (1 + frecencyScore)
},
})
return result.map((arr) => arr.obj)
}) })
createEffect(() => { createEffect(() => {

View file

@ -9,6 +9,7 @@ import { Account } from "@/account/account"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Git } from "@/git" import { Git } from "@/git"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Search } from "@opencode-ai/core/filesystem/search"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
@ -62,6 +63,7 @@ export const AppLayer = Layer.mergeAll(
Config.defaultLayer, Config.defaultLayer,
Git.defaultLayer, Git.defaultLayer,
Ripgrep.defaultLayer, Ripgrep.defaultLayer,
Search.defaultLayer,
Storage.defaultLayer, Storage.defaultLayer,
Snapshot.defaultLayer, Snapshot.defaultLayer,
Plugin.defaultLayer, Plugin.defaultLayer,

View file

@ -5,7 +5,9 @@ import { Snapshot } from "../snapshot"
import * as Project from "./project" import * as Project from "./project"
import * as Vcs from "./vcs" import * as Vcs from "./vcs"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { registerDisposer } from "@/effect/instance-registry"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import { Search } from "@opencode-ai/core/filesystem/search"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Service } from "./bootstrap-service" import { Service } from "./bootstrap-service"
@ -26,15 +28,25 @@ export const layer = Layer.effect(
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const project = yield* Project.Service const project = yield* Project.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
const search = yield* Search.Service
const shareNext = yield* ShareNext.Service const shareNext = yield* ShareNext.Service
const snapshot = yield* Snapshot.Service const snapshot = yield* Snapshot.Service
const vcs = yield* Vcs.Service const vcs = yield* Vcs.Service
// once we dispose the service - also release all the internal fff resources
const off = registerDisposer((directory) => Effect.runPromise(search.release(directory)))
yield* Effect.addFinalizer(() => Effect.sync(off))
const run = Effect.gen(function* () { const run = Effect.gen(function* () {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory)) yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory))
// everything depends on config so eager load it for nice traces // everything depends on config so eager load it for nice traces
yield* config.get() yield* config.get()
// in 99% of use cases user that is opened opencode at certain directory will
// conduct a file search in this direcotry, it could be switched later but
// mostly always we will need a file picker for cwd
// so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
yield* search.warm(ctx.directory).pipe(Effect.ignore)
// Plugin can mutate config so it has to be initialized before anything else. // Plugin can mutate config so it has to be initialized before anything else.
yield* plugin.init() yield* plugin.init()
// Each service self-manages its own slow work via Effect.forkScoped against // Each service self-manages its own slow work via Effect.forkScoped against
@ -58,6 +70,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Plugin.defaultLayer, Plugin.defaultLayer,
Project.defaultLayer, Project.defaultLayer,
Reference.defaultLayer, Reference.defaultLayer,
Search.defaultLayer,
ShareNext.defaultLayer, ShareNext.defaultLayer,
Snapshot.defaultLayer, Snapshot.defaultLayer,
Vcs.defaultLayer, Vcs.defaultLayer,

View file

@ -2,6 +2,7 @@ import * as InstanceState from "@/effect/instance-state"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Search } from "@opencode-ai/core/filesystem/search"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
@ -12,6 +13,7 @@ import { InstanceHttpApi } from "../api"
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service
const search = yield* Search.Service
const locations = yield* LocationServiceMap const locations = yield* LocationServiceMap
const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) { const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
@ -29,11 +31,18 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
}) { }) {
const directory = (yield* InstanceState.context).directory
const limit = ctx.query.limit ?? 10
const kind = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : "all")
// Prefer fff (frecency + fuzzy ranking) and trust its ordering. Fall back
// to the ripgrep-backed FileSystem.find when fff is unavailable.
const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie)
if (fff !== undefined) return fff
return (yield* filesystem( return (yield* filesystem(
FileSystem.Service.use((fs) => FileSystem.Service.use((fs) =>
fs.find({ fs.find({
query: ctx.query.query, query: ctx.query.query,
limit: ctx.query.limit ?? 10, limit,
type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined), type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined),
}), }),
), ),
@ -91,4 +100,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
.handle("content", content) .handle("content", content)
.handle("status", status) .handle("status", status)
}), }),
).pipe(Layer.provide(LocationServiceMap.layer)) ).pipe(Layer.provide(LocationServiceMap.layer), Layer.provide(Search.defaultLayer))

View file

@ -1,9 +1,8 @@
import path from "path" import path from "path"
import { Effect, Option, Schema } from "effect" import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./glob.txt" import DESCRIPTION from "./glob.txt"
import * as Tool from "./tool" import * as Tool from "./tool"
@ -19,9 +18,9 @@ export const Parameters = Schema.Struct({
export const GlobTool = Tool.define( export const GlobTool = Tool.define(
"glob", "glob",
Effect.gen(function* () { Effect.gen(function* () {
const rg = yield* Ripgrep.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
const searchSvc = yield* Search.Service
return { return {
description: DESCRIPTION, description: DESCRIPTION,
@ -52,36 +51,18 @@ export const GlobTool = Tool.define(
}) })
const limit = 100 const limit = 100
let truncated = false const files = yield* searchSvc.glob({
const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe( cwd: search,
Stream.mapEffect((file) => pattern: params.pattern,
Effect.gen(function* () { limit,
const full = path.resolve(search, file) signal: ctx.abort,
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) })
const mtime =
info?.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { path: full, mtime }
}),
),
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
if (files.length > limit) {
truncated = true
files.length = limit
}
files.sort((a, b) => b.mtime - a.mtime)
const output = [] const output = []
if (files.length === 0) output.push("No files found") if (files.files.length === 0) output.push("No files found")
if (files.length > 0) { if (files.files.length > 0) {
output.push(...files.map((file) => file.path)) output.push(...files.files)
if (truncated) { if (files.truncated) {
output.push("") output.push("")
output.push( output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`, `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
@ -92,8 +73,8 @@ export const GlobTool = Tool.define(
return { return {
title: path.relative(ins.worktree, search), title: path.relative(ins.worktree, search),
metadata: { metadata: {
count: files.length, count: files.files.length,
truncated, truncated: files.truncated,
}, },
output: output.join("\n"), output: output.join("\n"),
} }

View file

@ -1,6 +1,6 @@
- Fast file pattern matching tool that works with any codebase size - Fast file pattern matching tool that works with any codebase size
- Supports glob patterns like "**/*.js" or "src/**/*.ts" - Supports glob patterns like "**/*.js" or "src/**/*.ts"
- Returns matching file paths sorted by modification time - Returns matching file paths
- Use this tool when you need to find files by name patterns - Use this tool when you need to find files by name patterns
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. - You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.

View file

@ -1,9 +1,8 @@
import path from "path" import path from "path"
import { Schema } from "effect" import { Effect, Schema } from "effect"
import { Effect, Option } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import DESCRIPTION from "./grep.txt" import DESCRIPTION from "./grep.txt"
import * as Tool from "./tool" import * as Tool from "./tool"
@ -25,7 +24,7 @@ export const GrepTool = Tool.define(
"grep", "grep",
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const rg = yield* Ripgrep.Service const searchSvc = yield* Search.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
return { return {
@ -69,7 +68,7 @@ export const GrepTool = Tool.define(
const cwd = info?.type === "Directory" ? search : path.dirname(search) const cwd = info?.type === "Directory" ? search : path.dirname(search)
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
const result = yield* rg.search({ const result = yield* searchSvc.search({
cwd, cwd,
pattern: params.pattern, pattern: params.pattern,
glob: params.include ? [params.include] : undefined, glob: params.include ? [params.include] : undefined,
@ -83,38 +82,15 @@ export const GrepTool = Tool.define(
line: item.line_number, line: item.line_number,
text: item.lines.text, text: item.lines.text,
})) }))
const times = new Map(
(yield* Effect.forEach(
[...new Set(rows.map((row) => row.path))],
Effect.fnUntraced(function* (file) {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info || info.type === "Directory") return undefined
return [
file,
info.mtime.pipe(
Option.map((time) => time.getTime()),
Option.getOrElse(() => 0),
) ?? 0,
] as const
}),
{ concurrency: 16 },
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
)
const matches = rows.flatMap((row) => {
const mtime = times.get(row.path)
if (mtime === undefined) return []
return [{ ...row, mtime }]
})
matches.sort((a, b) => b.mtime - a.mtime)
const limit = 100 const limit = 100
const truncated = matches.length > limit const truncated = rows.length > limit
const final = truncated ? matches.slice(0, limit) : matches const final = truncated ? rows.slice(0, limit) : rows
if (final.length === 0) return empty if (final.length === 0) return empty
const total = matches.length const total = rows.length
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`] const hasMore = truncated || result.hasNextPage
const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
let current = "" let current = ""
for (const match of final) { for (const match of final) {
@ -135,11 +111,21 @@ export const GrepTool = Tool.define(
) )
} }
if (result.hasNextPage) {
output.push("")
output.push(`(Results truncated. Consider using a more specific path or pattern.)`)
}
if (result.partial) { if (result.partial) {
output.push("") output.push("")
output.push("(Some paths were inaccessible and skipped)") output.push("(Some paths were inaccessible and skipped)")
} }
if (result.regexFallbackError) {
output.push("")
output.push(`(Regex fallback: ${result.regexFallbackError})`)
}
return { return {
title: params.pattern, title: params.pattern,
metadata: { metadata: {

View file

@ -2,7 +2,7 @@
- Searches file contents using regular expressions - Searches file contents using regular expressions
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) - Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") - Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
- Returns file paths and line numbers with at least one match sorted by modification time - Returns file paths and line numbers with matching lines
- Use this tool when you need to find files containing specific patterns - Use this tool when you need to find files containing specific patterns
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead

View file

@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction" import { Instruction } from "../session/instruction"
import { Search } from "@opencode-ai/core/filesystem/search"
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
import { Reference } from "@/reference/reference" import { Reference } from "@/reference/reference"
@ -65,7 +66,7 @@ type Metadata = {
export const ReadTool = Tool.define< export const ReadTool = Tool.define<
typeof Parameters, typeof Parameters,
Metadata, Metadata,
FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Scope.Scope FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Search.Service | Scope.Scope
>( >(
"read", "read",
Effect.gen(function* () { Effect.gen(function* () {
@ -73,6 +74,7 @@ export const ReadTool = Tool.define<
const instruction = yield* Instruction.Service const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service const lsp = yield* LSP.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
const search = yield* Search.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
@ -117,6 +119,7 @@ export const ReadTool = Tool.define<
}) })
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) { const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
yield* search.open({ file: filepath }).pipe(Effect.ignore)
// LSP warm-up is optional; do not let a background defect fail an otherwise successful read. // LSP warm-up is optional; do not let a background defect fail an otherwise successful read.
yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope)) yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope))
}) })

View file

@ -34,7 +34,7 @@ import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { Format } from "../format" import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
@ -101,7 +101,7 @@ export const layer: Layer.Layer<
| EventV2Bridge.Service | EventV2Bridge.Service
| HttpClient.HttpClient | HttpClient.HttpClient
| ChildProcessSpawner | ChildProcessSpawner
| Ripgrep.Service | Search.Service
| Format.Service | Format.Service
| Truncate.Service | Truncate.Service
| RuntimeFlags.Service | RuntimeFlags.Service
@ -386,7 +386,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer), Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Search.defaultLayer),
Layer.provide(Truncate.defaultLayer), Layer.provide(Truncate.defaultLayer),
) )
.pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)), .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),

View file

@ -2,7 +2,7 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { Skill } from "../skill" import { Skill } from "../skill"
import * as Tool from "./tool" import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt" import DESCRIPTION from "./skill.txt"
@ -15,7 +15,7 @@ export const SkillTool = Tool.define(
"skill", "skill",
Effect.gen(function* () { Effect.gen(function* () {
const skill = yield* Skill.Service const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service const searchSvc = yield* Search.Service
return { return {
description: DESCRIPTION, description: DESCRIPTION,
@ -36,7 +36,7 @@ export const SkillTool = Tool.define(
const dir = path.dirname(info.location) const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href const base = pathToFileURL(dir).href
const limit = 10 const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe( const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")), Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)), Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit), Stream.take(limit),

View file

@ -30,13 +30,13 @@ const BACKGROUND_DESCRIPTION = [
].join(" ") ].join(" ")
const BACKGROUND_STARTED = [ const BACKGROUND_STARTED = [
"The task is working in the background. You will be notified automatically when it finishes.", "The task is working in the background. You will be notified automatically when it finishes.",
"Do not poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.", "Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n") ].join("\n")
const BACKGROUND_UPDATED = [ const BACKGROUND_UPDATED = [
"Additional context sent to the running background task.", "Additional context sent to the running background task.",
"The task is still working in the background. You will be notified automatically when it finishes.", "The task is still working in the background. You will be notified automatically when it finishes.",
"Do not poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you sent and end your response.", "Work on non-overlapping tasks, or briefly tell the user what you sent and end your response.",
].join("\n") ].join("\n")
@ -56,7 +56,8 @@ const BaseParameters = Schema.Struct(BaseParameterFields)
export const Parameters = Schema.Struct({ export const Parameters = Schema.Struct({
...BaseParameterFields, ...BaseParameterFields,
background: Schema.optional(Schema.Boolean).annotate({ background: Schema.optional(Schema.Boolean).annotate({
description: "Run the agent in the background. You will be notified when it completes.", description:
"Run the agent in the background. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress",
}), }),
}) })

View file

@ -10,6 +10,9 @@ import { afterAll } from "bun:test"
const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid) const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
afterAll(async () => { afterAll(async () => {
const { AppRuntime } = await import("../src/effect/app-runtime")
await AppRuntime.dispose()
const busy = (error: unknown) => const busy = (error: unknown) =>
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY" typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
const rm = async (left: number): Promise<void> => { const rm = async (left: number): Promise<void> => {

View file

@ -48,7 +48,7 @@ import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference" import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache" import { RepositoryCache } from "../../src/reference/repository-cache"
@ -196,7 +196,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
Layer.provide(RepositoryCache.defaultLayer), Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer), Layer.provide(Reference.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer), Layer.provide(Format.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provideMerge(todo), Layer.provideMerge(todo),

View file

@ -58,7 +58,7 @@ import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { Format } from "../../src/format" import { Format } from "../../src/format"
import { Reference } from "../../src/reference/reference" import { Reference } from "../../src/reference/reference"
import { RepositoryCache } from "../../src/reference/repository-cache" import { RepositoryCache } from "../../src/reference/repository-cache"
@ -142,7 +142,7 @@ function makeHttp() {
Layer.provide(RepositoryCache.defaultLayer), Layer.provide(RepositoryCache.defaultLayer),
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(Reference.defaultLayer), Layer.provide(Reference.defaultLayer),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Search.defaultLayer),
Layer.provide(Format.defaultLayer), Layer.provide(Format.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
Layer.provideMerge(todo), Layer.provideMerge(todo),

View file

@ -320,7 +320,7 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = `
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": { "properties": {
"background": { "background": {
"description": "Run the agent in the background. You will be notified when it completes.", "description": "Run the agent in the background. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress",
"type": "boolean", "type": "boolean",
}, },
"command": { "command": {

View file

@ -5,7 +5,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
import { GlobTool } from "../../src/tool/glob" import { GlobTool } from "../../src/tool/glob"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
@ -17,6 +17,7 @@ import { RepositoryCache } from "@/reference/repository-cache"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git" import { Git } from "@/git"
import { Filesystem } from "@/util/filesystem"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import type * as Tool from "../../src/tool/tool" import type * as Tool from "../../src/tool/tool"
@ -31,7 +32,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll( Layer.mergeAll(
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
FSUtil.defaultLayer, FSUtil.defaultLayer,
Ripgrep.defaultLayer, Search.defaultLayer,
Truncate.defaultLayer, Truncate.defaultLayer,
Agent.defaultLayer, Agent.defaultLayer,
Git.defaultLayer, Git.defaultLayer,
@ -40,6 +41,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
const it = testEffect(toolLayer()) const it = testEffect(toolLayer())
const references = testEffect(toolLayer({ experimentalReferences: true })) const references = testEffect(toolLayer({ experimentalReferences: true }))
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const ctx = { const ctx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
@ -172,7 +174,7 @@ describe("tool.glob", () => {
) )
expect(result.metadata.count).toBe(1) expect(result.metadata.count).toBe(1)
expect(result.output).toContain(path.join(cache, "src", "index.ts")) expect(full(result.output)).toContain(full(path.join(cache, "src", "index.ts")))
expect(items.find((item) => item.permission === "external_directory")).toBeUndefined() expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
}), }),
{ {

View file

@ -11,7 +11,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { Reference } from "@/reference/reference" import { Reference } from "@/reference/reference"
@ -34,7 +34,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll( Layer.mergeAll(
CrossSpawnSpawner.defaultLayer, CrossSpawnSpawner.defaultLayer,
FSUtil.defaultLayer, FSUtil.defaultLayer,
Ripgrep.defaultLayer, Search.defaultLayer,
Truncate.defaultLayer, Truncate.defaultLayer,
Agent.defaultLayer, Agent.defaultLayer,
Git.defaultLayer, Git.defaultLayer,

View file

@ -8,6 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { Search } from "@opencode-ai/core/filesystem/search"
import { LSP } from "@/lsp/lsp" import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@ -59,6 +60,7 @@ const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Instruction.defaultLayer, Instruction.defaultLayer,
LSP.defaultLayer, LSP.defaultLayer,
referenceLayer(flags), referenceLayer(flags),
Search.defaultLayer,
Truncate.defaultLayer, Truncate.defaultLayer,
) )

View file

@ -26,7 +26,7 @@ import { Instruction } from "@/session/instruction"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
import { Format } from "@/format" import { Format } from "@/format"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Search } from "@opencode-ai/core/filesystem/search"
import * as Truncate from "@/tool/truncate" import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { Reference } from "@/reference/reference" import { Reference } from "@/reference/reference"
@ -69,7 +69,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) =>
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer), Layer.provide(Format.defaultLayer),
Layer.provide(Layer.mergeAll(node, Database.defaultLayer)), Layer.provide(Layer.mergeAll(node, Database.defaultLayer)),
Layer.provide(Ripgrep.defaultLayer), Layer.provide(Search.defaultLayer),
Layer.provide(Truncate.defaultLayer), Layer.provide(Truncate.defaultLayer),
) )
.pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {}))) .pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))

View file

@ -8,6 +8,10 @@ const keybinds: Record<string, string> = {
"model.variant.cycle": "mod+shift+m", "model.variant.cycle": "mod+shift+m",
} }
export function formatKeybind(config: string) {
return config === "none" ? "" : config
}
export function useCommand() { export function useCommand() {
return { return {
options: [], options: [],

View file

@ -238,13 +238,13 @@ Do not port legacy provider model `reasoning`, `temperature`, or `interleaved` f
Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices. Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices.
| Field | Current Purpose | Status | Notes | | Field | Current Purpose | Status | Notes |
| --------------- | --------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | | --------------- | --------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------- |
| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. | | `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. |
| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. | | `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. |
| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. | | `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. |
| `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ permission, pattern, action }` rules. | | `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ action, resource, effect }` rules. |
| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. | | `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. |
Do not port `default_agent` ahead of the v2 agent design. The legacy runtime uses it to choose a visible, non-subagent fallback instead of `build`, but exposing that selection as an isolated top-level field would pre-commit v2 to the legacy agent model before agents and their policy surface are defined together. Do not port `default_agent` ahead of the v2 agent design. The legacy runtime uses it to choose a visible, non-subagent fallback instead of `build`, but exposing that selection as an isolated top-level field would pre-commit v2 to the legacy agent model before agents and their policy surface are defined together.
@ -281,7 +281,7 @@ Retain `description`, `hidden`, and `steps`; they define an agent's discoverabil
"color": "warning", "color": "warning",
"steps": 12, "steps": 12,
"disabled": false, "disabled": false,
"permissions": [{ "permission": "edit", "pattern": "*", "action": "deny" }], "permissions": [{ "action": "edit", "resource": "*", "effect": "deny" }],
}, },
}, },
} }
@ -289,13 +289,13 @@ Retain `description`, `hidden`, and `steps`; they define an agent's discoverabil
Do not port `tools`, either as a top-level setting or as an agent-entry alias. The legacy loader already converts tool booleans into permission rules, including collapsing write-adjacent tool names into `edit`; v2 should avoid carrying that lossy compatibility input forward. Do not port `tools`, either as a top-level setting or as an agent-entry alias. The legacy loader already converts tool booleans into permission rules, including collapsing write-adjacent tool names into `edit`; v2 should avoid carrying that lossy compatibility input forward.
Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` action in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries. Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` effect in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries.
```jsonc ```jsonc
{ {
"permissions": [ "permissions": [
{ "permission": "bash", "pattern": "*", "action": "ask" }, { "action": "bash", "resource": "*", "effect": "ask" },
{ "permission": "bash", "pattern": "git status", "action": "allow" }, { "action": "bash", "resource": "git status", "effect": "allow" },
], ],
} }
``` ```

186
specs/v2/tools.md Normal file
View file

@ -0,0 +1,186 @@
# V2 Tools
## Design
V2 has one opaque type for locally executable tools:
```ts
type Definition<Input, Output>
type AnyTool = Definition<any, any>
const make: <
Input extends Schema.Codec<any, any, never, never>,
Output extends Schema.Codec<any, any, never, never>,
>(config: {
readonly description: string
readonly input: Input
readonly output: Output
readonly execute: (
input: Schema.Type<Input>,
context: Tool.Context,
) => Effect.Effect<Schema.Type<Output>, ToolFailure>
readonly toModelOutput?: (input: {
readonly input: Schema.Type<Input>
readonly output: Output["Encoded"]
}) => ReadonlyArray<Tool.Content>
}) => Definition<Input, Output>
```
Application tools, built-ins, and statically authored plugin tools use this same constructor and execution contract.
`Tool.Definition` is opaque and has exactly one executor. Its schemas and executor are not public fields. The Tool module privately derives model definitions and interprets invocations for the registry; callers normally rely on `Tool.make` inference rather than naming the carrier type.
Input and output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`.
## Invocation Context
Every local tool receives the same concrete invocation context:
```ts
interface Tool.Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: Session.MessageID
readonly toolCallID: ToolCall.ID
}
```
`assistantMessageID` is the durable ID of the assistant message containing the call. The Session runner owns this association and supplies the complete context to the registry; the registry does not infer it.
Decoded tool input is passed separately to `execute`. Raw provider input and domain services do not belong in the invocation context.
Effect interruption is the cancellation mechanism. Tools may translate expected typed failures into `ToolFailure`, but must not translate interruption or defects into model-visible failures.
## Registration
Tools are named when registered:
```ts
yield *
tools.register({
read,
write,
grep,
})
```
The record key is the effective model-facing name. A reusable tool value has no intrinsic name.
```ts
interface Tools {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
```
Tool names use a conservative provider-neutral grammar and are validated at registration. Provider-specific restrictions that cannot be validated generically fail during request preparation with an explicit model-compatibility error.
Process application tools and Location tools expose the same `register` operation but retain separate services and stores. Registration placement determines scope, precedence, and authority; it does not change the tool type.
A Location plugin receives only the narrow `Tools` registration capability, not the internal registry. Its installation effect runs once per applicable Location, acquires that Location's services, constructs its tools, and registers them in the plugin-owned Scope.
Within one placement:
- The latest active registration for a name wins.
- Closing a registration removes only that registration.
- Closing the winner reveals the next-latest active registration.
- Mutating the caller's registration record later does not change the captured registration.
Location registrations take precedence over process application registrations.
## Built-In Tools
Built-ins use the same tool API while capturing trusted Location services:
```ts
const filesystem = yield * FileSystem.Service
const permission = yield * PermissionV2.Service
const tools = yield * Tools.Service
yield *
tools.register({
grep: Tool.make({
description: "Search file contents",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot(input)
yield* permission.assert({
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.assistantMessageID,
callID: context.toolCallID,
},
action: "grep",
resources: [input.pattern],
save: ["*"],
metadata: { root: root.resource },
})
return yield* filesystem.grep(input, root)
}).pipe(/* translate expected typed errors to ToolFailure */),
}),
})
```
Trusted tools formulate and sequence permission requests. `PermissionV2` evaluates policy and manages approval. The registry does not inject an `assertPermission` helper.
Sharing a tool type does not imply equal authority. Built-ins and trusted Location plugins may capture services that are not available to application tools.
## Execution
The Location-scoped registry owns effective lookup and settlement. For each local call it:
1. Resolves one effective named registration.
2. Decodes provider input with the input codec.
3. Invokes the tool with the runner-supplied context.
4. Encodes the returned output with the output codec.
5. Projects encoded output into model-facing content.
6. Bounds the complete model-facing output.
7. Returns the settlement and managed-output references to the runner, which persists them durably.
Invalid input never invokes the tool. Invalid output never produces a successful settlement.
`toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output.
Provider-turn materialization captures the effective registration identity for each advertised name without retaining its handler. Settlement rejects the call as stale if that registration was removed or replaced, including when closing an overlay reveals the previously effective registration. The current handler is captured only after this check; removing or replacing its registration afterward does not affect the running invocation.
## Output Bounding
Tools return complete validated domain output. They do not truncate model-facing output or manage retention files.
After projection, one generic settlement boundary bounds the channel actually sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping.
Model-output bounding is not producer memory management. Processes and streaming sources may need separate capture or spooling limits before a tool result exists. Those limits must be modeled at the producer boundary and must not masquerade as model-output truncation. A producer cannot claim a complete retained output after it has already discarded bytes.
## Failure Semantics
Outcomes remain distinct:
- `ToolFailure` is an expected model-visible failure.
- Interruption cancels the invocation and is not a tool result.
- Unexpected typed errors and defects follow the runner's operational failure policy.
- Unknown, invalid, and stale calls become explicit model-visible settlement errors without invoking a handler.
Leaf tools translate only errors they deliberately classify as recoverable. Broad cause-catching around an executor is invalid because it consumes interruption and defects.
## Laws
- **Single executor:** `Tool.make(config)` can invoke only `config.execute`.
- **Codec boundary:** execution observes decoded input; projection observes encoded output.
- **Durable identity:** invocation-owned records use the exact Session, agent, assistant message, and call IDs supplied by the runner.
- **Scoped registration:** closing a Scope removes exactly its registration and reveals any prior active overlay.
- **Captured execution:** registration changes cannot alter an invocation after effective lookup.
- **Stale rejection:** a call never executes a registration other than the one advertised for its provider turn.
- **Storage encapsulation:** domain output does not change according to model-output bounding or retention policy.
## Follow-Up
Location plugin installation should receive the same narrow `Tools` capability. That requires a separate Location-layer ordering change so built-ins register before plugins without introducing a `PluginBoot -> Tools -> PluginBoot` dependency cycle. The carrier, registrar, and plugin-owned Scope semantics are already suitable; no tool-specific plugin hook is needed.
Session's current public result shape still exposes managed `outputPaths`. Extending storage encapsulation across the public Session API requires a separate opaque managed-output reference design; paths are not entirely internal today.