mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffaaad89a4 | |||
| 8486bbcdc1 | |||
| 2621dde1c9 | |||
| a253c0437e | |||
| d8b3e528e8 | |||
| a5d6969f6f | |||
| 3954d7979d | |||
| a69a372f27 | |||
| 7300e7e10f | |||
| eeb870b0cc | |||
| 136bad9f17 | |||
| 17536a9237 | |||
| 315bc6ac2e | |||
| a183fb5615 | |||
| 2c1596ca80 | |||
| 9e082c2d43 | |||
| fa5ca85e30 | |||
| 37a8c6c916 | |||
| 23b0688a7f | |||
| d0dd49bd84 | |||
| db9a3b6c41 | |||
| 961b51b509 | |||
| 964f7f4254 | |||
| 8c27c8485e | |||
| 6721ff5328 | |||
| 026e0c634f | |||
| c581b59b7f | |||
| 5cc7811ec2 | |||
| 5d32845b02 | |||
| e9f5842f29 | |||
| 7b9734593a | |||
| 1dbfc7cfab | |||
| 3441b95afc | |||
| 306204bad8 | |||
| 47c8d85904 | |||
| c401076b6f | |||
| 1aef4de853 | |||
| 9c94634515 | |||
| c2b754bb03 | |||
| 2372edd5eb | |||
| a5f7f8d3b5 | |||
| 4df276b9e8 | |||
| 518af92c5b | |||
| 5d8c48718b | |||
| 41c5853739 | |||
| 8d989f6829 | |||
| 753fa4cd60 | |||
| d99a268ea5 | |||
| 1113adfd5e | |||
| 6afff5a55f | |||
| 71f5e4189d | |||
| b49bad9a86 | |||
| 2580f880a8 | |||
| 9d34029cd9 | |||
| 33296e7959 | |||
| 283258e95b |
@@ -75,7 +75,7 @@ jobs:
|
||||
build-cli:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build legacy CLI
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: |
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -377,7 +377,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -393,6 +393,7 @@ jobs:
|
||||
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
|
||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Package
|
||||
if: needs.version.outputs.release
|
||||
@@ -496,29 +497,31 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
pattern: opencode-node-cli-*
|
||||
path: packages/cli/dist/node
|
||||
|
||||
@@ -439,6 +439,7 @@
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
@@ -613,9 +614,9 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -903,7 +904,6 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
@@ -1089,9 +1089,9 @@
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -1109,9 +1109,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2169,27 +2169,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
|
||||
"@opentui/core": ["@opentui/core@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-darwin-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-arm64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-linux-x64-musl": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-arm64": "0.0.0-20260808-9ecf7c0a", "@opentui/core-win32-x64": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y9MKL8LMup4ebW3uZ57RSsPyOv37sIZxuWiHq4XJK9wrzpzTMnVomzxpWOI0S/QtLH2QzZ7znnbF+IuvIQ1hLQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Bn6fUZrwbUojkJG4YHowg/h/u3SPANmEivrl8YVbxBPCYOhZzQWxAVf7Fwg6PD0BpWk5gt9inQrir2DzWh5UpA=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "darwin", "cpu": "x64" }, "sha512-lrVskzbjcT9yJCUgjOpvPpEq2ipmuDyHTMJ+OebsJFdATdryJLzRAr5BZiFGXEo4CJ2CS/+6vT+ki7dmhpM+0g=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-czqIqdnBvyFRSJ0BEdNn7moRN0KQo5gvhSsOSfrzueHxsktgWcmk1cMeCvTkzC3JCBvCwlB1KnSdt4jlwNO1EQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "arm64" }, "sha512-6nixv6wlgjelUVJVHkJsCpuARb1rE/oxhIIthyLd062u5veoBt83cRGH9KmcZiwowKvso58hXn2KC21qAfylhQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-cxJMfrSsMsrZdU1Ke6awrxz0rdIJuI34l8Le3QlUbM5yLpp09ygJ7CElILMT50g3F+EnRf6BKvVmkbRrDzFQ6g=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.0.0-20260808-9ecf7c0a", "", { "os": "linux", "cpu": "x64" }, "sha512-7zVbZ0XgCDi3yImmd9Cj+e0WhSuQzWo1ziSQiFgPCR8cs/dfrpYLgBlX9PP0gFOcJKeRccJJv7rnBetl3CB7tg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "arm64" }, "sha512-K3SNYJDvB7XD5nxi8jt4jWtgmqPG0Qbob0Px9afqMH3g/R5hIoi2P3GDbqn8JNv82c4dPIGJjXXuNhHa0tZQbw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260808-9ecf7c0a", "", { "os": "win32", "cpu": "x64" }, "sha512-3JCpPS8+Gz3PWlQHiFwqYuJ0AM0vKlfbe4+O+0m2EYej/QjN5KjL1nBfVLq5XDj4dVPEIktQKN6e//YzSzdaqA=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@opentui/core": "0.0.0-20260808-9ecf7c0a" }, "peerDependencies": { "@opentui/react": "0.0.0-20260808-9ecf7c0a", "@opentui/solid": "0.0.0-20260808-9ecf7c0a", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-aGxw6P0RJSuUU3y3QtAnKa1B6tQShxR0mjmnN3Cadr4LoduSuWn/l1kE+27KwH0ap9WTAeGVsN7u2oawB9eWKw=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.0.0-20260808-9ecf7c0a", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260808-9ecf7c0a", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Q7ouv2KxZKO0/Sh0RbhNd7kEw6E7tlrU+yw36xxlnyXOqlK3TkQ+u1RMSZulYAn8mP20pnYLM9OxY4pOC/wK8Q=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
@@ -3539,7 +3539,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -3623,8 +3623,6 @@
|
||||
|
||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||
|
||||
"clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
@@ -4041,7 +4039,7 @@
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||
|
||||
@@ -4323,7 +4321,7 @@
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||
|
||||
@@ -4467,8 +4465,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
@@ -4807,7 +4803,7 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
@@ -4933,7 +4929,7 @@
|
||||
|
||||
"npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
|
||||
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
@@ -4959,7 +4955,7 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
@@ -5561,7 +5557,7 @@
|
||||
|
||||
"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@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
@@ -5593,8 +5589,6 @@
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
@@ -6533,8 +6527,6 @@
|
||||
|
||||
"@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.1", "", { "dependencies": { "@vercel/cli-config": "0.2.1", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-ufdalm2MWOYksyj8KVpWjoOFPJO6zoYpuyvIggIQ2bB0CFCjTCiTkGXHqAKwG77GVRjOaN3/8S5ITlZpXWmqOw=="],
|
||||
|
||||
"@vercel/nft/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
@@ -6719,9 +6711,11 @@
|
||||
|
||||
"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@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
|
||||
"execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
@@ -6797,8 +6791,6 @@
|
||||
|
||||
"node-gyp/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
@@ -7565,20 +7557,6 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
@@ -8639,8 +8617,6 @@
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
|
||||
"@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
+6
-6
@@ -46,9 +46,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.4.5",
|
||||
"@opentui/keymap": "0.4.5",
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -150,9 +150,9 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
|
||||
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
|
||||
@@ -348,7 +348,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -392,7 +395,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
}
|
||||
|
||||
return messages
|
||||
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:bedrock-converse",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"parallel"
|
||||
],
|
||||
"name": "bedrock-converse/continues-after-parallel-tool-results",
|
||||
"recordedAt": "2026-08-11T16:41:46.482Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Compare the weather in Paris and London.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"weather_paris\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}},{\"toolUse\":{\"toolUseId\":\"weather_london\",\"name\":\"get_weather\",\"input\":{\"city\":\"London\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"weather_paris\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}},{\"toolResult\":{\"toolUseId\":\"weather_london\",\"content\":[{\"json\":{\"temperature\":14,\"condition\":\"rainy\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"After receiving both tool results, reply exactly: Paris is sunny; London is rainy.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/vnd.amazon.eventstream"
|
||||
},
|
||||
"body": "AAAAqAAAAFKgEDvmCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFEiLCJyb2xlIjoiYXNzaXN0YW50In189ig4AAAAzgAAAFfGCE2ECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlBhcmlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifWttETIAAADDAAAAVz6YiTULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE0ifRHJ8Q0AAACqAAAAV6q6nAkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHN1bm55In0sInAiOiJhYmNkZWZnaGlqayJ98ZCy6gAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiI7In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2In020bBKAAAAygAAAFcziOtECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBMb25kb24ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUCJ9ew04hAAAAK4AAABXXzo6yQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9yK3bdAAAALcAAABXMsrPOgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcmFpbnkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eCJ9JoCYVwAAALoAAABXyloLiws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRiJ9WJwR8wAAAMEAAABXRFjaVQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU4ifYzp4V0AAAChAAAAVqptnY4LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQyJ9AHyeLwAAAJUAAABRYKgWaws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0Iiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn2HCXz0AAABBgAAAE6wWpX7CzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTA5MH0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo1MjEsIm91dHB1dFRva2VucyI6OSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjUzMH19Uwfxiw==",
|
||||
"bodyEncoding": "base64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -255,6 +255,57 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_parallel_history",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Compare the weather."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_paris", name: "lookup", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "tool_london", name: "lookup", input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({ id: "tool_paris", name: "lookup", result: { forecast: "sunny" } }),
|
||||
Message.tool({ id: "tool_london", name: "lookup", result: { forecast: "rainy" } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "Compare the weather." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ toolUse: { toolUseId: "tool_paris", name: "lookup", input: { city: "Paris" } } },
|
||||
{ toolUse: { toolUseId: "tool_london", name: "lookup", input: { city: "London" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_paris",
|
||||
content: [{ json: { forecast: "sunny" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_london",
|
||||
content: [{ json: { forecast: "rainy" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers image content in tool-result messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1165,4 +1216,39 @@ describe("Bedrock Converse recorded", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("continues after parallel tool results", { tags: ["tool", "tool-loop", "parallel"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_bedrock_parallel_tool_results",
|
||||
model: recordedModel(),
|
||||
system: "After receiving both tool results, reply exactly: Paris is sunny; London is rainy.",
|
||||
messages: [
|
||||
Message.user("Compare the weather in Paris and London."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "weather_paris", name: weatherToolName, input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "weather_london", name: weatherToolName, input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "weather_paris",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 22, condition: "sunny" },
|
||||
}),
|
||||
Message.tool({
|
||||
id: "weather_london",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 14, condition: "rainy" },
|
||||
}),
|
||||
],
|
||||
tools: [weatherTool],
|
||||
cache: "none",
|
||||
generation: { maxTokens: 40, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim()).toBe("Paris is sunny; London is rainy.")
|
||||
expect(response.finishReason?.normalized).toBe("stop")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -69,6 +69,63 @@ describe("v2 session reducer", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers durable selection predecessors and derives them for older events", () => {
|
||||
const source: SessionMessageInfo[] = [
|
||||
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_previous_model",
|
||||
type: "model-switched",
|
||||
model: { id: "old", providerID: "provider" },
|
||||
time: { created: 1 },
|
||||
},
|
||||
]
|
||||
const reducer = createV2SessionReducer()
|
||||
|
||||
const agent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
|
||||
}),
|
||||
)
|
||||
const model = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_model",
|
||||
type: "session.model.selected",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
model: { id: "new", providerID: "provider" },
|
||||
previous: { id: "durable", providerID: "provider" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const legacyAgent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_legacy_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
|
||||
expect(model?.messages.at(-1)).toMatchObject({
|
||||
type: "model-switched",
|
||||
model: { id: "new" },
|
||||
previous: { id: "durable" },
|
||||
})
|
||||
expect(legacyAgent?.messages.at(-1)).toMatchObject({
|
||||
type: "agent-switched",
|
||||
agent: "plan",
|
||||
previous: "build",
|
||||
})
|
||||
})
|
||||
|
||||
test("folds tool, retry, and completion events", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
|
||||
@@ -61,6 +61,12 @@ export function createV2SessionReducer() {
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
|
||||
item.type === "agent-switched" || item.type === "assistant",
|
||||
)?.agent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.model.selected":
|
||||
@@ -69,10 +75,12 @@ export function createV2SessionReducer() {
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous: source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
|
||||
@@ -351,8 +351,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.location.directory }
|
||||
const current = info[key]
|
||||
console.log({ tab, session, current })
|
||||
if (current?.title === next.title && current.directory === next.directory) return
|
||||
if (current && current.title === next.title && current.directory === next.directory) return
|
||||
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
|
||||
setInfo(key, next)
|
||||
},
|
||||
select: navigateTab,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { Window } from "happy-dom"
|
||||
import { Node, Window } from "happy-dom"
|
||||
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("matches only the scroll element or an ancestor containing it", () => {
|
||||
@@ -18,6 +18,7 @@ test("matches only the scroll element or an ancestor containing it", () => {
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const route = targetWindow.document.createElement("section")
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const unrelated = targetWindow.document.createElement("div")
|
||||
@@ -40,24 +41,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
targetWindow.document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([])
|
||||
try {
|
||||
mutations.append(targetWindow.document.body, unrelated)
|
||||
mutations.remove(unrelated)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await waitFor(() => calls.length === 1, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
} finally {
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
@@ -204,7 +205,33 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
|
||||
const deadline = targetWindow.performance.now() + 1_000
|
||||
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
|
||||
function controlledMutations(targetWindow: Window) {
|
||||
let emit: (record: MutationRecord) => void = () => {
|
||||
throw new Error("Mutation observer is not active")
|
||||
}
|
||||
class ControlledMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
emit = (record) => callback([record], this as unknown as MutationObserver)
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
|
||||
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
|
||||
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
|
||||
return {
|
||||
append(parent: Node, node: Node) {
|
||||
parent.appendChild(node)
|
||||
emit(record(parent, [node], []))
|
||||
},
|
||||
remove(node: Node) {
|
||||
const parent = node.parentNode
|
||||
if (!parent) throw new Error("Mutation target has no parent")
|
||||
parent.removeChild(node)
|
||||
emit(record(parent, [], [node]))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,8 +422,7 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant:
|
||||
defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Config } from "../../config"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -16,6 +17,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
mismatch: "replace",
|
||||
})
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||
@@ -39,6 +41,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
config: {
|
||||
update: (update) => runServicePromise(config.update(update)),
|
||||
},
|
||||
paths: { home: global.home, state: global.state, log: global.log },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import fs from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -129,13 +128,9 @@ export async function usingInteractiveStdin<T>(
|
||||
export function createMiniHost(input: {
|
||||
terminal: InteractiveStdin
|
||||
directory: string
|
||||
paths?: { home: string; state: string; log: string }
|
||||
paths: { home: string; state: string; log: string }
|
||||
}): MiniHost {
|
||||
const paths = input.paths ?? {
|
||||
home: Global.Path.home,
|
||||
state: Global.Path.state,
|
||||
log: Global.Path.log,
|
||||
}
|
||||
const paths = input.paths
|
||||
const diagnostics = {
|
||||
pid: process.pid,
|
||||
cwd: input.directory,
|
||||
|
||||
@@ -22,6 +22,7 @@ export type MiniCommandInput = {
|
||||
demo?: boolean
|
||||
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||
config?: MiniFrontendInput["config"]
|
||||
paths: { home: string; state: string; log: string }
|
||||
}
|
||||
|
||||
type Model = MiniFrontendInput["model"]
|
||||
@@ -104,7 +105,7 @@ export async function runMini(input: MiniCommandInput) {
|
||||
}))
|
||||
const frontend = await frontendTask
|
||||
return frontend.runMiniFrontend({
|
||||
host: createMiniHost({ terminal, directory }),
|
||||
host: createMiniHost({ terminal, directory, paths: input.paths }),
|
||||
sdk,
|
||||
directory,
|
||||
target: resolveTarget,
|
||||
|
||||
@@ -39,7 +39,8 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
})
|
||||
|
||||
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
|
||||
@@ -3,6 +3,38 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
|
||||
describe("acp service lifecycle", () => {
|
||||
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
||||
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
|
||||
await using fixture = makeACPFixture({
|
||||
models: [model],
|
||||
defaultModel: model,
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_default_variant", {
|
||||
model: { providerID: model.providerID, id: model.id },
|
||||
}),
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
expect(fixture.requests).toContainEqual({
|
||||
method: "POST",
|
||||
path: "/api/session",
|
||||
query: {},
|
||||
body: {
|
||||
location: { directory: "/workspace" },
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "second-model" },
|
||||
},
|
||||
})
|
||||
expect(currentValue(created, "effort")).toBe("none")
|
||||
})
|
||||
|
||||
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
|
||||
@@ -339,7 +339,11 @@ export type Endpoint5_31Output =
|
||||
readonly type: "session.agent.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly previous?: Agent.ID | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -348,7 +352,11 @@ export type Endpoint5_31Output =
|
||||
readonly type: "session.model.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly model: Model.Ref
|
||||
readonly previous?: Model.Ref | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
|
||||
@@ -41,6 +41,7 @@ export type SessionMessageAgentSelected = {
|
||||
time: { created: number }
|
||||
type: "agent-switched"
|
||||
agent: string
|
||||
previous?: string
|
||||
}
|
||||
|
||||
export type PromptBase64 = string
|
||||
@@ -435,7 +436,7 @@ export type SessionAgentSelected = {
|
||||
type: "session.agent.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; agent: string }
|
||||
data: { sessionID: string; agent: string; previous?: string }
|
||||
}
|
||||
|
||||
export type SessionModelSelected = {
|
||||
@@ -445,7 +446,7 @@ export type SessionModelSelected = {
|
||||
type: "session.model.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; model: ModelRef }
|
||||
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
|
||||
}
|
||||
|
||||
export type SessionMoved = {
|
||||
@@ -2535,6 +2536,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -2786,6 +2788,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -3037,6 +3040,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "15060ec5-05f7-4b86-b2a5-9108609432b3",
|
||||
"prevIds": ["1551a157-8959-4ba9-a52b-4ea3b7b28cae"],
|
||||
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
|
||||
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1302,6 +1302,16 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "resume_attempts",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
|
||||
@@ -194,11 +194,10 @@ async function formatTypescript(input: string) {
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
|
||||
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
|
||||
])
|
||||
).map((module) => module.default)
|
||||
export const migrations = [
|
||||
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
`
|
||||
}
|
||||
|
||||
@@ -508,8 +508,23 @@ function toolOutput(result: ToolResultValue) {
|
||||
case "text":
|
||||
case "error":
|
||||
return { type: "text" as const, value: messageValue(result.value) }
|
||||
case "content":
|
||||
return {
|
||||
type: "content" as const,
|
||||
value: result.value.map((item) => {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text }
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
const image = item.mime.toLowerCase().startsWith("image/")
|
||||
if (data !== undefined)
|
||||
return image
|
||||
? { type: "image-data" as const, data, mediaType: item.mime }
|
||||
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
|
||||
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
|
||||
}),
|
||||
}
|
||||
case "json":
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
|
||||
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
@@ -61,6 +62,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
const processes = yield* AppProcess.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "command",
|
||||
initial: () => ({ commands: new Map() }),
|
||||
@@ -111,6 +113,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
location,
|
||||
processes,
|
||||
shell: options,
|
||||
bin: global.bin,
|
||||
})
|
||||
|
||||
const prompt = (yield* mcp.prompts()).find(
|
||||
@@ -164,6 +167,7 @@ function evaluateTemplate(
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
@@ -197,11 +201,16 @@ const evaluateShell = Effect.fnUntraced(function* (
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
|
||||
const shell = ShellSelect.preferred(
|
||||
Config.latest(yield* services.config.entries(), "shell"),
|
||||
services.shell,
|
||||
services.bin,
|
||||
)
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
@@ -262,7 +271,7 @@ export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -83,8 +83,14 @@ export function normalize(input: unknown): Result {
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
||||
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
||||
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
|
||||
const nativeReferences = decodeMap(
|
||||
input.references,
|
||||
ConfigReference.Entry,
|
||||
["references"],
|
||||
diagnostics,
|
||||
decodeEncoded,
|
||||
)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"references",
|
||||
@@ -94,13 +100,13 @@ export function normalize(input: unknown): Result {
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
|
||||
diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
||||
const migratedCommands = mapValues(legacyCommands, (value) => {
|
||||
const migrated = ConfigMigrateV1.commands({ value })?.value
|
||||
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
||||
})
|
||||
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
||||
const nativeCommands = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"commands",
|
||||
@@ -110,8 +116,9 @@ export function normalize(input: unknown): Result {
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
const legacyAgents = mapValues(
|
||||
decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics, decodeValue),
|
||||
(value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
)
|
||||
const legacySmallModel = own(input, "small_model")
|
||||
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
|
||||
@@ -130,11 +137,11 @@ export function normalize(input: unknown): Result {
|
||||
model: migratedSmallModel,
|
||||
...legacyAgents.title,
|
||||
}
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||
)
|
||||
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
||||
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
||||
const nativeAgents = decodeMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics, decodeEncoded)
|
||||
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
||||
mergeMap(
|
||||
@@ -147,7 +154,7 @@ export function normalize(input: unknown): Result {
|
||||
)
|
||||
|
||||
const legacyProviders = migrateProviders(input.provider, diagnostics)
|
||||
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
||||
const nativeProviders = decodeMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics, decodeEncoded)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"providers",
|
||||
@@ -159,14 +166,14 @@ export function normalize(input: unknown): Result {
|
||||
|
||||
const toolRules = migrateTools(input.tools, diagnostics)
|
||||
const permissionRules = migratePermissions(input.permission, diagnostics)
|
||||
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
||||
const nativePermissions = decodeList(input.permissions, Permission.Rule, ["permissions"], diagnostics, decodeEncoded)
|
||||
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
||||
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
||||
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics, decodeValue).map(
|
||||
(plugin) => (typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }),
|
||||
)
|
||||
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
||||
const nativePlugins = decodeList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics, decodeEncoded)
|
||||
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
||||
encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
||||
|
||||
@@ -200,7 +207,7 @@ export function normalize(input: unknown): Result {
|
||||
overlay(encoded, key, value, [key], diagnostics)
|
||||
})
|
||||
|
||||
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
||||
const instructions = decodeList(input.instructions, Schema.String, ["instructions"], diagnostics, decodeEncoded)
|
||||
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
||||
|
||||
return { type: "normalized", encoded, diagnostics }
|
||||
@@ -209,7 +216,7 @@ export function normalize(input: unknown): Result {
|
||||
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
if (!own(input, "skills")) return
|
||||
if (Array.isArray(input.skills)) {
|
||||
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
||||
encoded.skills = decodeList(input.skills, Schema.String, ["skills"], diagnostics, decodeEncoded)
|
||||
return
|
||||
}
|
||||
if (!isRecord(input.skills)) {
|
||||
@@ -217,8 +224,8 @@ function normalizeSkills(input: Record<string, unknown>, encoded: Record<string,
|
||||
return
|
||||
}
|
||||
encoded.skills = [
|
||||
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
||||
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
||||
...decodeList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics, decodeEncoded),
|
||||
...decodeList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics, decodeEncoded),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -248,8 +255,8 @@ function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, un
|
||||
return
|
||||
}
|
||||
if (name === "servers" && !isDirectLegacyMcp(value)) {
|
||||
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
||||
setOwn(nativeServers, key, server),
|
||||
Object.entries(decodeMap(value, ConfigMCP.Server, path, diagnostics, decodeEncoded)).forEach(
|
||||
([key, server]) => setOwn(nativeServers, key, server),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -404,7 +411,13 @@ function normalizeExperimental(
|
||||
if (value !== undefined) result.subagent_depth = value
|
||||
}
|
||||
native.push(
|
||||
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
||||
...decodeList(
|
||||
experimental.policies,
|
||||
ConfigPolicy.Info,
|
||||
["experimental", "policies"],
|
||||
diagnostics,
|
||||
decodeEncoded,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -420,7 +433,7 @@ function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string
|
||||
invalid(["watcher"], diagnostics)
|
||||
return
|
||||
}
|
||||
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
||||
const ignore = decodeList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics, decodeEncoded)
|
||||
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
||||
}
|
||||
|
||||
@@ -435,7 +448,7 @@ function normalizeFormatter(
|
||||
if (value !== undefined) encoded.formatter = value
|
||||
return
|
||||
}
|
||||
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
||||
const entries = decodeMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics, decodeEncoded)
|
||||
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
||||
encoded.formatter = entries
|
||||
}
|
||||
@@ -447,7 +460,7 @@ function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, un
|
||||
if (value !== undefined) encoded.lsp = value
|
||||
return
|
||||
}
|
||||
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
||||
const entries = decodeMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics, decodeEncoded)
|
||||
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
||||
}
|
||||
|
||||
@@ -597,78 +610,44 @@ function decodeProviderList(
|
||||
return {
|
||||
present: true,
|
||||
nonEmpty: input[key].length > 0,
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics),
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics, decodeValue),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never>, A>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
|
||||
): Record<string, A> {
|
||||
if (value === undefined) return {}
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
||||
Object.entries(value).flatMap(([name, raw]): [string, A][] => {
|
||||
const decoded = decode(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
function decodeList<S extends Schema.Codec<unknown, unknown, never>, A>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return {} as Record<string, S["Type"]>
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {} as Record<string, S["Type"]>
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
) as Record<string, S["Type"]>
|
||||
}
|
||||
|
||||
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Encoded"][]
|
||||
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
|
||||
): A[] {
|
||||
if (value === undefined) return []
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return [] as S["Encoded"][]
|
||||
return []
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Type"][]
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return [] as S["Type"][]
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
|
||||
const decoded = decode(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
export * as ConfigInstructionPlugin from "./instruction"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery"
|
||||
import { Instructions } from "../../instructions/index"
|
||||
import { Location } from "../../location"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
|
||||
type Loaded =
|
||||
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
|
||||
| { readonly type: "unavailable" }
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.instruction",
|
||||
effect: Effect.fn(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
const home = yield* fs.resolve(global.home)
|
||||
const project = discovery.project && FSUtil.contains(root, start)
|
||||
const stop = FSUtil.contains(home, start) ? home : root
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
|
||||
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
|
||||
const candidates = [
|
||||
globalFile,
|
||||
...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []),
|
||||
]
|
||||
for (const path of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path, type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
|
||||
}
|
||||
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
|
||||
const content = yield* fs.readFileStringSafe(path)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
|
||||
})
|
||||
|
||||
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
|
||||
const file = yield* read(globalFile)
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
|
||||
if (!project) return []
|
||||
const discovered = new Set(
|
||||
yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve),
|
||||
)
|
||||
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
|
||||
if (files.some((file) => file === undefined)) return Instructions.unavailable
|
||||
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
|
||||
})
|
||||
|
||||
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load instruction source", { source, cause }).pipe(
|
||||
Effect.as(Instructions.unavailable),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
yield* discovery.transform((draft) => {
|
||||
if (loaded.current.type === "unavailable") {
|
||||
draft.unavailable()
|
||||
return
|
||||
}
|
||||
for (const file of loaded.current.files) draft.add(file)
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
|
||||
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
|
||||
Effect.asVoid,
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (start === stop) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export * as SkillFile from "./skill-file"
|
||||
|
||||
import path from "path"
|
||||
import { Result, Schema, type SchemaIssue, SchemaParser } from "effect"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
slash: Schema.Boolean.pipe(Schema.optional),
|
||||
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
|
||||
|
||||
export type ParseResult =
|
||||
| { readonly _tag: "Parsed"; readonly skill: Skill.Info }
|
||||
| { readonly _tag: "Skipped"; readonly reason: "markdown" }
|
||||
| { readonly _tag: "Skipped"; readonly reason: "frontmatter"; readonly issue: SchemaIssue.Issue }
|
||||
|
||||
const metadataBoolean = (metadata: unknown, key: string) => {
|
||||
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined
|
||||
}
|
||||
const value = Reflect.get(metadata, key)
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value !== "string") return undefined
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === "true") return true
|
||||
if (normalized === "false") return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parse(directory: string, filepath: string, content: string): ParseResult {
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) return { _tag: "Skipped", reason: "markdown" }
|
||||
const decoded = decodeFrontmatter(markdown.data)
|
||||
if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure }
|
||||
const frontmatter = decoded.success
|
||||
const id =
|
||||
path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath))
|
||||
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
|
||||
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
|
||||
return {
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make(id),
|
||||
name: Skill.Name.make(frontmatter.name ?? id),
|
||||
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
|
||||
...(slash === undefined ? {} : { slash }),
|
||||
...(autoinvoke === undefined ? {} : { autoinvoke }),
|
||||
location: AbsolutePath.make(filepath),
|
||||
content: markdown.content,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,191 @@
|
||||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../../location"
|
||||
import { SkillDiscovery } from "../../skill/discovery"
|
||||
import { SkillFile } from "./skill-file"
|
||||
|
||||
type Source = Skill.DirectorySource | Skill.UrlSource
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.skill",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const discovery = yield* SkillDiscovery.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
const watcher = yield* Watcher.Service
|
||||
const loaded: { entries: Entry[]; skills: Skill.Info[] } = {
|
||||
entries: yield* config.entries(),
|
||||
skills: [],
|
||||
}
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe({ path: target, type })
|
||||
yield* FiberMap.run(
|
||||
watches,
|
||||
`${type}:${target}`,
|
||||
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
|
||||
{ onlyIfMissing: true, startImmediately: true },
|
||||
)
|
||||
})
|
||||
|
||||
function firstMissing(target: string): Effect.Effect<string | undefined> {
|
||||
const parent = path.dirname(target)
|
||||
if (parent === target) return Effect.succeed(undefined)
|
||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
||||
}
|
||||
|
||||
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn(
|
||||
"ConfigSkillPlugin.watchDirectory",
|
||||
)(function* (directory: string) {
|
||||
const target = path.resolve(directory)
|
||||
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (resolved) {
|
||||
yield* watch(resolved, "directory")
|
||||
if (resolved !== target) yield* watch(target, "file")
|
||||
return resolved === target ? [target] : [target, resolved]
|
||||
}
|
||||
const missing = yield* firstMissing(target)
|
||||
if (missing) yield* watch(missing, "file")
|
||||
if (
|
||||
yield* fs.realPath(directory).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
) {
|
||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
||||
return yield* watchDirectory(directory)
|
||||
}
|
||||
return [target]
|
||||
})
|
||||
|
||||
const sources = () => {
|
||||
const result: Source[] = []
|
||||
const add = (source: Source) => {
|
||||
if (result.some((item) => Skill.Source.equals(item, source))) return
|
||||
result.push(source)
|
||||
}
|
||||
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
|
||||
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
|
||||
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of [...claude, ...agents]) {
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
||||
}
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(Skill.UrlSource.make({ type: "url", url: item }))
|
||||
add(Skill.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
add(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const load = Effect.fn("ConfigSkillPlugin.load")(function* (source: Source) {
|
||||
const directories =
|
||||
source.type === "directory"
|
||||
? [source.path]
|
||||
: yield* discovery.pull(source.url).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load skill source", {
|
||||
source: Skill.Source.key(source),
|
||||
cause,
|
||||
}).pipe(Effect.as([] as AbsolutePath[])),
|
||||
),
|
||||
)
|
||||
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
|
||||
const skills: Skill.Info[] = []
|
||||
for (const directory of directories) {
|
||||
const files = yield* fs
|
||||
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
for (const filepath of files.toSorted()) {
|
||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
|
||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!content) continue
|
||||
const parsed = SkillFile.parse(directory, filepath, content)
|
||||
if (parsed._tag === "Skipped") {
|
||||
yield* Effect.logDebug("skill file skipped", {
|
||||
filepath,
|
||||
reason: parsed.reason,
|
||||
...(parsed.reason === "frontmatter" ? { issue: parsed.issue } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
skills.push(parsed.skill)
|
||||
}
|
||||
}
|
||||
yield* Effect.logDebug("skill source loaded", {
|
||||
source: Skill.Source.key(source),
|
||||
type: source.type,
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.id),
|
||||
})
|
||||
return skills
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* FiberMap.clear(watches)
|
||||
const skills = new Map<Skill.ID, Skill.Info>()
|
||||
const current = sources()
|
||||
for (const source of current) {
|
||||
for (const skill of yield* load(source)) skills.set(skill.id, skill)
|
||||
}
|
||||
loaded.skills = Array.from(skills.values())
|
||||
if (file) {
|
||||
yield* Effect.logInfo("skills rescanned", {
|
||||
file,
|
||||
sources: current.map(Skill.Source.key),
|
||||
skills: loaded.skills.map((skill) => skill.id),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
for (const skill of loaded.skills) draft.add(skill)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(refresh()),
|
||||
Effect.andThen(ctx.skill.reload()),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
export * as ConfigPluginSource from "./source"
|
||||
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
|
||||
readonly changes: () => Stream.Stream<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPluginSource") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
const watchConfiguredSources = Effect.fn("ConfigPluginSource.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
operations: Effect.fn("ConfigPluginSource.operations")(function* () {
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(fs, location, entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
return operations
|
||||
}),
|
||||
changes: () =>
|
||||
Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
Stream.fromPubSub(configuredChanges),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Watcher.node, Location.node],
|
||||
})
|
||||
|
||||
export const empty = makeLocationNode({
|
||||
service: Service,
|
||||
layer: Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
operations: () => Effect.succeed([]),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
location: Location.Interface,
|
||||
entries: readonly Entry[],
|
||||
) {
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
const Package = Schema.Struct({
|
||||
exports: Schema.optional(Schema.Unknown),
|
||||
module: Schema.optional(Schema.Unknown),
|
||||
main: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
const decodePackage = Schema.decodeUnknownOption(Package)
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const children = (yield* Effect.forEach(sourceDirectories, (source) =>
|
||||
fs.readDirectoryEntries(path.join(directory, source)).pipe(
|
||||
Effect.orElseSucceed(() => []),
|
||||
Effect.map((entries) =>
|
||||
entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })),
|
||||
),
|
||||
),
|
||||
))
|
||||
.flat()
|
||||
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
|
||||
const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry))
|
||||
return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
|
||||
if (entry.type === "file" && source) return Option.some(entry.target)
|
||||
if (entry.type === "directory") return yield* discoverPackage(fs, entry.target)
|
||||
if (entry.type !== "symlink") return Option.none<string>()
|
||||
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target)
|
||||
return Option.none<string>()
|
||||
})
|
||||
}
|
||||
|
||||
function discoverPackage(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* fs.resolve(directory)
|
||||
const manifest = yield* fs
|
||||
.readJson(path.join(directory, "package.json"))
|
||||
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
|
||||
const configured = Option.isSome(manifest)
|
||||
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
|
||||
: []
|
||||
return yield* Effect.findFirst(
|
||||
[...configured, "index.ts", "index.js"]
|
||||
.filter((entry) => !path.isAbsolute(entry))
|
||||
.map((entry) => path.resolve(directory, entry))
|
||||
.filter((entry) => FSUtil.contains(directory, entry)),
|
||||
(entry) =>
|
||||
fs
|
||||
.isFile(entry)
|
||||
.pipe(
|
||||
Effect.flatMap((exists) =>
|
||||
exists
|
||||
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
|
||||
)
|
||||
}
|
||||
@@ -40,16 +40,19 @@ const databaseLayer = Layer.effect(
|
||||
)
|
||||
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.suspend(() => {
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
return provide(join(Global.Path.data, filename))
|
||||
})
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
return provide(join(global.data, filename))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
}
|
||||
|
||||
export const node = configured({ path: ":memory:" })
|
||||
|
||||
+86
-45
@@ -1,47 +1,88 @@
|
||||
import type { DatabaseMigration } from "./migration"
|
||||
import m00 from "./migration/20260127222353_familiar_lady_ursula"
|
||||
import m01 from "./migration/20260211171708_add_project_commands"
|
||||
import m02 from "./migration/20260213144116_wakeful_the_professor"
|
||||
import m03 from "./migration/20260225215848_workspace"
|
||||
import m04 from "./migration/20260227213759_add_session_workspace_id"
|
||||
import m05 from "./migration/20260228203230_blue_harpoon"
|
||||
import m06 from "./migration/20260303231226_add_workspace_fields"
|
||||
import m07 from "./migration/20260309230000_move_org_to_state"
|
||||
import m08 from "./migration/20260312043431_session_message_cursor"
|
||||
import m09 from "./migration/20260323234822_events"
|
||||
import m10 from "./migration/20260410174513_workspace-name"
|
||||
import m11 from "./migration/20260413175956_chief_energizer"
|
||||
import m12 from "./migration/20260423070820_add_icon_url_override"
|
||||
import m13 from "./migration/20260427172553_slow_nightmare"
|
||||
import m14 from "./migration/20260428004200_add_session_path"
|
||||
import m15 from "./migration/20260501142318_next_venus"
|
||||
import m16 from "./migration/20260504145000_add_sync_owner"
|
||||
import m17 from "./migration/20260507164347_add_workspace_time"
|
||||
import m18 from "./migration/20260510033149_session_usage"
|
||||
import m19 from "./migration/20260511000411_data_migration_state"
|
||||
import m20 from "./migration/20260511173437_session-metadata"
|
||||
import m21 from "./migration/20260601010001_normalize_storage_paths"
|
||||
import m22 from "./migration/20260601202201_amazing_prowler"
|
||||
import m23 from "./migration/20260602002951_lowly_union_jack"
|
||||
import m24 from "./migration/20260602182828_add_project_directories"
|
||||
import m25 from "./migration/20260603001617_session_message_projection_indexes"
|
||||
import m26 from "./migration/20260603040000_session_message_projection_order"
|
||||
import m27 from "./migration/20260603141458_session_input_inbox"
|
||||
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
|
||||
import m29 from "./migration/20260604172448_event_sourced_session_input"
|
||||
import m30 from "./migration/20260605003541_add_session_context_snapshot"
|
||||
import m31 from "./migration/20260605042240_add_context_epoch_agent"
|
||||
import m32 from "./migration/20260611035744_credential"
|
||||
import m33 from "./migration/20260611192811_lush_chimera"
|
||||
import m34 from "./migration/20260612174303_project_dir_strategy"
|
||||
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
|
||||
import m36 from "./migration/20260622170816_reset_v2_session_state"
|
||||
import m37 from "./migration/20260622202450_simplify_session_input"
|
||||
import m38 from "./migration/20260804233008_loose_psylocke"
|
||||
import m39 from "./migration/20260805200742_import_legacy_credentials"
|
||||
import m40 from "./migration/20260808023530_workspace_domain"
|
||||
import m41 from "./migration/20260811161259_execution_claim_attempts"
|
||||
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
import("./migration/20260127222353_familiar_lady_ursula"),
|
||||
import("./migration/20260211171708_add_project_commands"),
|
||||
import("./migration/20260213144116_wakeful_the_professor"),
|
||||
import("./migration/20260225215848_workspace"),
|
||||
import("./migration/20260227213759_add_session_workspace_id"),
|
||||
import("./migration/20260228203230_blue_harpoon"),
|
||||
import("./migration/20260303231226_add_workspace_fields"),
|
||||
import("./migration/20260309230000_move_org_to_state"),
|
||||
import("./migration/20260312043431_session_message_cursor"),
|
||||
import("./migration/20260323234822_events"),
|
||||
import("./migration/20260410174513_workspace-name"),
|
||||
import("./migration/20260413175956_chief_energizer"),
|
||||
import("./migration/20260423070820_add_icon_url_override"),
|
||||
import("./migration/20260427172553_slow_nightmare"),
|
||||
import("./migration/20260428004200_add_session_path"),
|
||||
import("./migration/20260501142318_next_venus"),
|
||||
import("./migration/20260504145000_add_sync_owner"),
|
||||
import("./migration/20260507164347_add_workspace_time"),
|
||||
import("./migration/20260510033149_session_usage"),
|
||||
import("./migration/20260511000411_data_migration_state"),
|
||||
import("./migration/20260511173437_session-metadata"),
|
||||
import("./migration/20260601010001_normalize_storage_paths"),
|
||||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
import("./migration/20260603001617_session_message_projection_indexes"),
|
||||
import("./migration/20260603040000_session_message_projection_order"),
|
||||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604172448_event_sourced_session_input"),
|
||||
import("./migration/20260605003541_add_session_context_snapshot"),
|
||||
import("./migration/20260605042240_add_context_epoch_agent"),
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
import("./migration/20260805200742_import_legacy_credentials"),
|
||||
import("./migration/20260808023530_workspace_domain"),
|
||||
])
|
||||
).map((module) => module.default)
|
||||
export const migrations = [
|
||||
m00,
|
||||
m01,
|
||||
m02,
|
||||
m03,
|
||||
m04,
|
||||
m05,
|
||||
m06,
|
||||
m07,
|
||||
m08,
|
||||
m09,
|
||||
m10,
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m15,
|
||||
m16,
|
||||
m17,
|
||||
m18,
|
||||
m19,
|
||||
m20,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m25,
|
||||
m26,
|
||||
m27,
|
||||
m28,
|
||||
m29,
|
||||
m30,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m35,
|
||||
m36,
|
||||
m37,
|
||||
m38,
|
||||
m39,
|
||||
m40,
|
||||
m41,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect, Semaphore } from "effect"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
|
||||
@@ -13,7 +14,7 @@ const lock = Semaphore.makeUnsafe(1)
|
||||
export type Migration = {
|
||||
id: string
|
||||
foreignKeys?: boolean
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown>
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
|
||||
}
|
||||
|
||||
export function apply(db: Database) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
@@ -33,7 +34,10 @@ const wellKnownSourcesKey = "wellknown:sources"
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260805200742_import_legacy_credentials",
|
||||
up(tx) {
|
||||
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
|
||||
return Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json"))
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -41,9 +45,9 @@ export default migration
|
||||
|
||||
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
|
||||
return Effect.gen(function* () {
|
||||
const file = Bun.file(filepath)
|
||||
if (!(yield* Effect.promise(() => file.exists()))) return
|
||||
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
|
||||
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
|
||||
if (content === undefined) return
|
||||
const input = Option.getOrUndefined(decodeJson(content))
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260811161259_execution_claim_attempts",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`resume_attempts\` integer DEFAULT 0 NOT NULL;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -200,6 +200,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
@@ -1,58 +1,32 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Database, type SQLQueryBindings } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly export: Effect.Effect<Uint8Array, SqlError>
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
readonly updateValues: never
|
||||
}
|
||||
|
||||
interface Config {
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
readonly create?: boolean
|
||||
readonly readwrite?: boolean
|
||||
readonly disableWAL?: boolean
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
interface SqliteConnection extends Connection {
|
||||
readonly export: Effect.Effect<Uint8Array, SqlError>
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
}
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as Database
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
|
||||
const statement = native.query(query)
|
||||
const statement = native.query<Record<string, unknown>, SQLQueryBindings[]>(query)
|
||||
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
|
||||
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
|
||||
try {
|
||||
return Effect.succeed((statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>)
|
||||
return Effect.succeed(statement.all(...(params as SQLQueryBindings[])) ?? [])
|
||||
} catch (cause) {
|
||||
return Effect.fail(
|
||||
new SqlError({
|
||||
@@ -64,11 +38,11 @@ const make = (options: Config) =>
|
||||
|
||||
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => {
|
||||
const statement = native.query(query)
|
||||
const statement = native.query<unknown, SQLQueryBindings[]>(query)
|
||||
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
|
||||
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
|
||||
try {
|
||||
return Effect.succeed((statement.values(...(params as any)) ?? []) as Array<unknown[]>)
|
||||
return Effect.succeed(statement.values(...(params as SQLQueryBindings[])) ?? [])
|
||||
} catch (cause) {
|
||||
return Effect.fail(
|
||||
new SqlError({
|
||||
@@ -78,25 +52,7 @@ const make = (options: Config) =>
|
||||
}
|
||||
})
|
||||
|
||||
const connection = identity<SqliteConnection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
const connection = Sqlite.makeConnection(run, runValues, {
|
||||
export: Effect.try({
|
||||
try: () => native.serialize(),
|
||||
catch: (cause) =>
|
||||
@@ -104,7 +60,7 @@ const make = (options: Config) =>
|
||||
reason: classifySqliteError(cause, { message: "Failed to export database", operation: "export" }),
|
||||
}),
|
||||
}),
|
||||
loadExtension: (path) =>
|
||||
loadExtension: (path: string) =>
|
||||
Effect.try({
|
||||
try: () => native.loadExtension(path),
|
||||
catch: (cause) =>
|
||||
@@ -114,37 +70,10 @@ const make = (options: Config) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
export: Effect.flatMap(acquirer, (_) => _.export),
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
|
||||
export: Effect.flatMap(acquirer, (_) => _.export),
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
}))
|
||||
})
|
||||
|
||||
const nativeLayer = (config: Config) =>
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
readonly updateValues: never
|
||||
}
|
||||
|
||||
interface Config {
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
readonly create?: boolean
|
||||
@@ -28,24 +16,12 @@ interface Config {
|
||||
readonly disableWAL?: boolean
|
||||
readonly timeout?: number
|
||||
readonly allowExtension?: boolean
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
interface SqliteConnection extends Connection {
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
}
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DatabaseSync
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
|
||||
const statement = native.prepare(query)
|
||||
@@ -79,26 +55,8 @@ const make = (options: Config) =>
|
||||
}
|
||||
})
|
||||
|
||||
const connection = identity<SqliteConnection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
loadExtension: (path) =>
|
||||
const connection = Sqlite.makeConnection(run, runValues, {
|
||||
loadExtension: (path: string) =>
|
||||
Effect.try({
|
||||
try: () => native.loadExtension(path),
|
||||
catch: (cause) =>
|
||||
@@ -108,36 +66,9 @@ const make = (options: Config) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
}))
|
||||
})
|
||||
|
||||
const nativeLayer = (config: Config) =>
|
||||
|
||||
@@ -1,8 +1,100 @@
|
||||
export * as Sqlite from "./sqlite"
|
||||
|
||||
import { Context } from "effect"
|
||||
import { Context, Effect, Fiber, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import type { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import type { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
|
||||
export type DrizzleClient = ReturnType<typeof drizzle>
|
||||
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
|
||||
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
|
||||
|
||||
export interface ClientConfig {
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
type Run = (
|
||||
query: string,
|
||||
params?: ReadonlyArray<unknown>,
|
||||
) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, SqlError>
|
||||
|
||||
type RunValues = (
|
||||
query: string,
|
||||
params?: ReadonlyArray<unknown>,
|
||||
) => Effect.Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>
|
||||
|
||||
export const makeConnection = <Extensions extends object>(run: Run, runValues: RunValues, extensions: Extensions) =>
|
||||
identity<Connection & Extensions>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
...extensions,
|
||||
})
|
||||
|
||||
export const makeClient = <
|
||||
Config extends ClientConfig,
|
||||
SqliteConnection extends Connection,
|
||||
const TypeId extends string,
|
||||
Extensions extends object,
|
||||
>(
|
||||
options: Config,
|
||||
connection: SqliteConnection,
|
||||
typeId: TypeId,
|
||||
extensions: (acquirer: Effect.Effect<SqliteConnection, SqlError, Scope.Scope>) => Extensions,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
return Object.assign(
|
||||
yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler: Statement.makeCompilerSqlite(options.transformQueryNames),
|
||||
transactionAcquirer,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
["db.system.name", "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
}),
|
||||
{
|
||||
[typeId]: typeId,
|
||||
config: options,
|
||||
...extensions(acquirer),
|
||||
},
|
||||
) as SqlClient.SqlClient &
|
||||
Record<TypeId, TypeId> & {
|
||||
readonly config: Config
|
||||
readonly updateValues: never
|
||||
} & Extensions
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { KVTable } from "../kv/sql"
|
||||
import { EventSequenceTable, EventTable } from "../event/sql"
|
||||
import { EventSequenceTable } from "../event/sql"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { existsSync } from "node:fs"
|
||||
@@ -161,6 +161,7 @@ type NextMessage = {
|
||||
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const MIGRATION_STATE_KEY = "migration.v1-v2"
|
||||
const EVENT_DELETE_BATCH_SIZE = 1_000
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
|
||||
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
|
||||
@@ -467,10 +468,11 @@ function updateProgress(progress: Progress) {
|
||||
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
|
||||
}
|
||||
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service> {
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
const state = yield* readState(db)
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
@@ -478,13 +480,21 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]')
|
||||
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
|
||||
`)
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx.delete(EventTable).run()
|
||||
while (true) {
|
||||
yield* tx.run(sql`
|
||||
DELETE FROM event
|
||||
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
|
||||
`)
|
||||
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
|
||||
if (deleted < EVENT_DELETE_BATCH_SIZE) break
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
@@ -492,7 +502,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options))
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
@@ -502,7 +512,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options), (completed) => {
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
@@ -621,10 +631,10 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
)
|
||||
}
|
||||
|
||||
function nextPath(options: Options) {
|
||||
function nextPath(options: Options, data: string) {
|
||||
if (options.nextDatabasePath) return options.nextDatabasePath
|
||||
if (process.env.OPENCODE_DB === ":memory:") return undefined
|
||||
return path.join(Global.Path.data, "opencode-next.db")
|
||||
return path.join(data, "opencode-next.db")
|
||||
}
|
||||
|
||||
function openNextDatabase(sourcePath: string) {
|
||||
|
||||
@@ -1,102 +1,15 @@
|
||||
import {
|
||||
FileFinder,
|
||||
type DirItem,
|
||||
type DirSearchResult,
|
||||
type FileItem,
|
||||
type InitOptions,
|
||||
type MixedItem,
|
||||
type MixedSearchResult,
|
||||
type SearchResult,
|
||||
} from "@ff-labs/fff-bun"
|
||||
import { FileFinder } from "@ff-labs/fff-bun"
|
||||
import { bind } from "./fff"
|
||||
|
||||
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
|
||||
|
||||
declare global {
|
||||
const FFF_LIBC: "gnu" | "musl"
|
||||
}
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
const adapter = bind(FileFinder)
|
||||
|
||||
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 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>
|
||||
directorySearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<DirSearch>
|
||||
mixedSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<MixedSearch>
|
||||
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),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
}
|
||||
}
|
||||
export const available = adapter.available
|
||||
export const create = adapter.create
|
||||
|
||||
export * as Fff from "./fff.bun"
|
||||
|
||||
@@ -1,100 +1,12 @@
|
||||
import type {
|
||||
DirItem,
|
||||
DirSearchResult,
|
||||
FileItem,
|
||||
InitOptions,
|
||||
MixedItem,
|
||||
MixedSearchResult,
|
||||
SearchResult,
|
||||
} from "@ff-labs/fff-node"
|
||||
import { bind } from "./fff"
|
||||
|
||||
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
|
||||
|
||||
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
const adapter = bind(FileFinder, "fff unavailable on node runtime")
|
||||
|
||||
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 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>
|
||||
directorySearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<DirSearch>
|
||||
mixedSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<MixedSearch>
|
||||
trackQuery(query: string, file: string): Result<boolean>
|
||||
getHistoricalQuery(offset: number): Result<string | null>
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return FileFinder?.isAvailable() ?? false
|
||||
}
|
||||
|
||||
export function create(opts: Init): Result<Picker> {
|
||||
if (!FileFinder) return { ok: false, error: "fff unavailable on node runtime" }
|
||||
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),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
}
|
||||
}
|
||||
export const available = adapter.available
|
||||
export const create = adapter.create
|
||||
|
||||
export * as Fff from "./fff.node"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export interface Init {
|
||||
basePath: string
|
||||
aiMode?: boolean
|
||||
disableMmapCache?: boolean
|
||||
disableContentIndexing?: boolean
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface File {
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
export interface Directory {
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
|
||||
|
||||
export interface Score {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Search {
|
||||
items: File[]
|
||||
scores: Score[]
|
||||
}
|
||||
|
||||
export interface DirSearch {
|
||||
items: Directory[]
|
||||
scores: Score[]
|
||||
}
|
||||
|
||||
export interface MixedSearch {
|
||||
items: Mixed[]
|
||||
scores: Score[]
|
||||
}
|
||||
|
||||
export interface Picker {
|
||||
destroy(): void
|
||||
fileSearch(query: string, options?: SearchOptions): Result<Search>
|
||||
directorySearch(query: string, options?: SearchOptions): Result<DirSearch>
|
||||
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearch>
|
||||
}
|
||||
|
||||
export interface Backend {
|
||||
isAvailable(): boolean
|
||||
create(options: Init): Result<Picker>
|
||||
}
|
||||
|
||||
export function bind(backend: Backend | undefined, unavailable = "fff unavailable") {
|
||||
return {
|
||||
available: () => backend?.isAvailable() ?? false,
|
||||
create: (options: Init): Result<Picker> =>
|
||||
backend?.create(options) ?? {
|
||||
ok: false,
|
||||
error: unavailable,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { make, type Info } from "./formatter/builtins"
|
||||
@@ -25,6 +26,7 @@ const layer = Layer.effect(
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const global = yield* Global.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
@@ -42,6 +44,7 @@ const layer = Layer.effect(
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
@@ -122,5 +125,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
})
|
||||
|
||||
@@ -18,8 +18,10 @@ export function make(input: {
|
||||
readonly fs: FSUtil.Interface
|
||||
readonly npm: Npm.Interface
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly bin: string
|
||||
}) {
|
||||
const disabled = false as const
|
||||
const findExecutable = (name: string) => which(name, undefined, input.bin)
|
||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
|
||||
const commandOutput = (command: string[]) =>
|
||||
@@ -37,7 +39,7 @@ export function make(input: {
|
||||
name: "gofmt",
|
||||
extensions: [".go"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("gofmt")
|
||||
const match = findExecutable("gofmt")
|
||||
return match ? [match, "-w", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -46,7 +48,7 @@ export function make(input: {
|
||||
name: "mix",
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("mix")
|
||||
const match = findExecutable("mix")
|
||||
return match ? [match, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export function make(input: {
|
||||
name: "zig",
|
||||
extensions: [".zig", ".zon"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("zig")
|
||||
const match = findExecutable("zig")
|
||||
return match ? [match, "fmt", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -159,7 +161,7 @@ export function make(input: {
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".clang-format")).length) return disabled
|
||||
const match = which("clang-format")
|
||||
const match = findExecutable("clang-format")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
@@ -168,7 +170,7 @@ export function make(input: {
|
||||
name: "ktlint",
|
||||
extensions: [".kt", ".kts"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("ktlint")
|
||||
const match = findExecutable("ktlint")
|
||||
return match ? [match, "-F", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -177,17 +179,18 @@ export function make(input: {
|
||||
name: "ruff",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!which("ruff")) return disabled
|
||||
const bin = findExecutable("ruff")
|
||||
if (!bin) return disabled
|
||||
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
|
||||
const found = yield* findUp(config)
|
||||
if (!found.length) continue
|
||||
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
|
||||
return ["ruff", "format", "$FILE"]
|
||||
return [bin, "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
|
||||
const found = yield* findUp(dependency)
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return [bin, "format", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
@@ -197,7 +200,7 @@ export function make(input: {
|
||||
name: "air",
|
||||
extensions: [".R"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("air")
|
||||
const bin = findExecutable("air")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "--help"])
|
||||
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
|
||||
@@ -210,34 +213,34 @@ export function make(input: {
|
||||
name: "uv",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("uv")
|
||||
const bin = findExecutable("uv")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "format", "--help"])
|
||||
return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"])
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], findExecutable)
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], findExecutable)
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], findExecutable)
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"], findExecutable)
|
||||
|
||||
const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
extensions: [".ml", ".mli"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".ocamlformat")).length) return disabled
|
||||
const match = which("ocamlformat")
|
||||
const match = findExecutable("ocamlformat")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], findExecutable)
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], findExecutable)
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], findExecutable)
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], findExecutable)
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], findExecutable)
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], findExecutable)
|
||||
|
||||
const pint: Info = {
|
||||
name: "pint",
|
||||
@@ -253,9 +256,9 @@ export function make(input: {
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], findExecutable)
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable)
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable)
|
||||
|
||||
return [
|
||||
gofmt,
|
||||
@@ -287,12 +290,17 @@ export function make(input: {
|
||||
] satisfies Info[]
|
||||
}
|
||||
|
||||
function executable(name: string, extensions: readonly string[], args: string[]): Info {
|
||||
function executable(
|
||||
name: string,
|
||||
extensions: readonly string[],
|
||||
args: string[],
|
||||
findExecutable: (name: string) => string | null,
|
||||
): Info {
|
||||
return {
|
||||
name,
|
||||
extensions,
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which(name)
|
||||
const match = findExecutable(name)
|
||||
return match ? [match, ...args] : false
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
export * as InstructionDiscovery from "./instruction-discovery"
|
||||
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import { isAbsolute, join, relative, sep } from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { Instructions } from "./instructions/index"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { Instructions } from "./instructions/index"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { State } from "./state"
|
||||
|
||||
class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
path: AbsolutePath,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
@@ -17,7 +15,30 @@ class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
const Files = Schema.Array(File)
|
||||
const key = Instructions.Key.make("core/instructions")
|
||||
|
||||
export interface Interface {
|
||||
export const Event = {
|
||||
Updated: Bus.ephemeral({ type: "instruction-discovery.updated", schema: {} }),
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
files: Map<AbsolutePath, Types.DeepMutable<File>>
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
list: () => readonly Types.DeepMutable<File>[]
|
||||
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
|
||||
// sibling contributors interleave by transform registration order.
|
||||
add: (file: File) => void
|
||||
update: (path: string, update: (file: Types.DeepMutable<File>) => void) => void
|
||||
remove: (path: string) => void
|
||||
unavailable: () => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
// Discovery policy lives here because internal plugins have no per-composition options channel.
|
||||
// Move it into plugin config once plugins can consume their own options.
|
||||
readonly project: boolean
|
||||
readonly list: () => Effect.Effect<File[] | Instructions.Unavailable>
|
||||
readonly load: () => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
@@ -32,9 +53,26 @@ export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const bus = yield* Bus.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "instruction-discovery",
|
||||
initial: () => ({ files: new Map(), available: true }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.files.values()),
|
||||
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
|
||||
update: (path, update) => {
|
||||
const current = draft.files.get(AbsolutePath.make(path))
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.path = AbsolutePath.make(path)
|
||||
},
|
||||
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
|
||||
unavailable: () => {
|
||||
draft.available = false
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
Instructions.make<ReadonlyArray<File>>({
|
||||
@@ -49,52 +87,22 @@ export const layer = (options?: Options) =>
|
||||
},
|
||||
})
|
||||
|
||||
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const fromProject = relative(stop, start)
|
||||
const insideProject =
|
||||
fromProject === "" ||
|
||||
(fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
|
||||
const discovered = new Set(
|
||||
yield* Effect.forEach(
|
||||
options?.project === false || !insideProject
|
||||
? []
|
||||
: yield* fs.up({
|
||||
targets: ["AGENTS.md"],
|
||||
start,
|
||||
stop,
|
||||
}),
|
||||
fs.resolve,
|
||||
),
|
||||
)
|
||||
const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
|
||||
const files = yield* Effect.forEach(
|
||||
paths,
|
||||
(path) =>
|
||||
fs
|
||||
.readFileStringSafe(path)
|
||||
.pipe(
|
||||
Effect.map((content) =>
|
||||
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
|
||||
return Instructions.unavailable
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
const list = Effect.fn("InstructionDiscovery.list")(function* () {
|
||||
const current = state.get()
|
||||
if (!current.available) return Instructions.unavailable
|
||||
return Array.from(current.files.values())
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
load: () =>
|
||||
observe().pipe(
|
||||
Effect.map((files) =>
|
||||
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
),
|
||||
project: options?.project !== false,
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list,
|
||||
load: Effect.fn("InstructionDiscovery.load")(function* () {
|
||||
const files = yield* list()
|
||||
if (!Array.isArray(files)) return source(files)
|
||||
return source(files.length === 0 ? Instructions.removed : files)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -103,7 +111,7 @@ export function configured(options?: Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [FSUtil.node, Global.node, Location.node],
|
||||
deps: [Bus.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import path from "path"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bus } from "./bus"
|
||||
@@ -13,6 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Model } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { KV } from "./kv"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
@@ -537,6 +535,18 @@ export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
|
||||
|
||||
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const Cache = Schema.Struct({
|
||||
updatedAt: Schema.Number,
|
||||
body: CatalogJson,
|
||||
})
|
||||
const defaultSource = "https://models.opencode.ai"
|
||||
|
||||
function cacheKey(source: string) {
|
||||
if (source === defaultSource) return "models-dev:catalog"
|
||||
return `models-dev:catalog:${Hash.fast(source)}`
|
||||
}
|
||||
|
||||
export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
@@ -544,6 +554,7 @@ export const layer = (options?: Options) =>
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const app = yield* App.Metadata
|
||||
const kv = yield* KV.Service
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
HttpClient.retryTransient({
|
||||
@@ -554,21 +565,28 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
)
|
||||
|
||||
const source = options?.url || "https://models.opencode.ai"
|
||||
const source = options?.url || defaultSource
|
||||
const fetch = options?.fetch ?? true
|
||||
const userAgent = App.useragent(app)
|
||||
const filepath = path.join(
|
||||
Global.Path.cache,
|
||||
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
|
||||
)
|
||||
const key = cacheKey(source)
|
||||
const ttl = Duration.minutes(5)
|
||||
const lockKey = `models-dev:${filepath}`
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const loadFromCache = Effect.fnUntraced(function* () {
|
||||
const value = yield* kv.get(key)
|
||||
const cached = Schema.decodeUnknownOption(Cache)(value)
|
||||
if (Option.isSome(cached))
|
||||
return {
|
||||
catalog: cached.value.body as Record<string, SourceProvider>,
|
||||
updatedAt: cached.value.updatedAt,
|
||||
}
|
||||
if (value !== undefined) yield* kv.remove(key)
|
||||
})
|
||||
|
||||
const fresh = Effect.fnUntraced(function* () {
|
||||
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!stat) return false
|
||||
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
|
||||
return Date.now() - mtime < Duration.toMillis(ttl)
|
||||
const cached = yield* loadFromCache()
|
||||
if (!cached) return false
|
||||
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
|
||||
})
|
||||
|
||||
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
|
||||
@@ -580,15 +598,12 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
})
|
||||
|
||||
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.catch((error) => {
|
||||
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") {
|
||||
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
|
||||
}
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
)
|
||||
const loadFromFile = options?.file
|
||||
? fs.readJson(options.file).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: Effect.succeed(undefined)
|
||||
|
||||
const loadSnapshot = Effect.sync(() =>
|
||||
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
|
||||
@@ -596,33 +611,36 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
|
||||
yield* fs.writeWithDirs(tempfile, text).pipe(
|
||||
Effect.andThen(fs.rename(tempfile, filepath)),
|
||||
Effect.catch((error) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
|
||||
return yield* Effect.fail(error)
|
||||
}),
|
||||
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
|
||||
// Best-effort: a cache-write failure must never kill catalog
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
),
|
||||
)
|
||||
return text
|
||||
return catalog
|
||||
})
|
||||
|
||||
const populate = Effect.gen(function* () {
|
||||
const fromDisk = yield* loadFromDisk
|
||||
if (fromDisk) return normalize(fromDisk)
|
||||
const fromFile = yield* loadFromFile
|
||||
if (fromFile) return normalize(fromFile)
|
||||
const cached = options?.file ? undefined : yield* loadFromCache()
|
||||
if (cached) return normalize(cached.catalog)
|
||||
const bundled = yield* loadSnapshot
|
||||
if (bundled) return normalize(bundled)
|
||||
if (!fetch) return []
|
||||
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
|
||||
const text = yield* Effect.scoped(
|
||||
const catalog = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* Flock.effect(lockKey)
|
||||
const stored = options?.file ? undefined : yield* loadFromCache()
|
||||
if (stored) return stored.catalog
|
||||
return yield* fetchAndWrite()
|
||||
}),
|
||||
)
|
||||
return normalize(JSON.parse(text) as Record<string, SourceProvider>)
|
||||
return normalize(catalog)
|
||||
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
|
||||
|
||||
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
|
||||
@@ -630,21 +648,19 @@ export const layer = (options?: Options) =>
|
||||
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
|
||||
|
||||
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Flock.effect(lockKey)
|
||||
// Re-check under the lock: another process may have refreshed between
|
||||
// our outer check and lock acquisition.
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* fetchAndWrite()
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
}),
|
||||
).pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
|
||||
Effect.ignore,
|
||||
)
|
||||
yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* fetchAndWrite()
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
|
||||
if (fetch && !process.argv.includes("--get-yargs-completions")) {
|
||||
@@ -660,7 +676,7 @@ export function configured(options?: Options) {
|
||||
return makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [FSUtil.node, Bus.node, App.node, httpClient],
|
||||
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export { Event, ID, Info } from "@opencode-ai/schema/plugin"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "./app"
|
||||
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
|
||||
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Agent } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
@@ -44,7 +44,12 @@ const layer = Layer.effect(
|
||||
const inherit = yield* State.inherit()
|
||||
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
|
||||
inherit,
|
||||
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
Context.make(Scope.Scope, child).pipe(
|
||||
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
|
||||
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
|
||||
),
|
||||
),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
|
||||
@@ -101,16 +101,6 @@ export const Plugin = define({
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("plan"), (item) => {
|
||||
item.name = Agent.Name.make("Plan")
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
)
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("general"), (item) => {
|
||||
item.name = Agent.Name.make("General")
|
||||
item.description =
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PluginHooks from "./hooks"
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { State } from "../state"
|
||||
@@ -15,19 +15,29 @@ export interface Domains {
|
||||
readonly tool: ToolHooks
|
||||
}
|
||||
|
||||
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
||||
type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
|
||||
|
||||
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
|
||||
interface Failures extends Record<keyof Domains, unknown> {
|
||||
readonly aisdk: NoFailures<AISDKHooks>
|
||||
readonly session: NoFailures<SessionHooks>
|
||||
readonly shell: NoFailures<ShellHooks>
|
||||
readonly tool: ToolFailures
|
||||
}
|
||||
|
||||
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
||||
|
||||
export interface Interface {
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
callback: Callback<Domains[Domain][Name]>,
|
||||
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
event: Domains[Domain][Name],
|
||||
) => Effect.Effect<Domains[Domain][Name]>
|
||||
) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
|
||||
@@ -56,7 +66,9 @@ const layer = Layer.effect(
|
||||
|
||||
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
|
||||
for (const callback of callbacks.get(key(domain, name)) ?? []) {
|
||||
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
|
||||
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
|
||||
event,
|
||||
])
|
||||
yield* result
|
||||
}
|
||||
return event
|
||||
|
||||
@@ -290,8 +290,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
transform: (callback) =>
|
||||
skill.transform((draft) => {
|
||||
callback({
|
||||
source: (source) => draft.source(Schema.decodeUnknownSync(Skill.Source)(source)),
|
||||
list: draft.list,
|
||||
list: () => mutable(draft.list()),
|
||||
add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
|
||||
update: draft.update,
|
||||
remove: draft.remove,
|
||||
})
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export * as PluginInternal from "./internal"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Agent } from "../agent"
|
||||
@@ -10,10 +12,12 @@ import { Config } from "../config"
|
||||
import { Credential } from "../credential"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigPluginSource } from "../config/plugin/source"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
@@ -24,6 +28,7 @@ import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { InstructionDiscovery } from "../instruction-discovery"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
@@ -37,6 +42,8 @@ import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
import { Skill } from "../skill"
|
||||
import { SkillDiscovery } from "../skill/discovery"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { PatchTool } from "../tool/plugin/patch"
|
||||
import { EditTool } from "../tool/plugin/edit"
|
||||
import { GlobTool } from "../tool/plugin/glob"
|
||||
@@ -54,6 +61,7 @@ import { WellKnown } from "../wellknown"
|
||||
import { WriteTool } from "../tool/plugin/write"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { PlanPlugin } from "./plan"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { WebSearchPlugins } from "./websearch"
|
||||
@@ -70,6 +78,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const command = yield* Command.Service
|
||||
const config = yield* Config.Service
|
||||
const credential = yield* Credential.Service
|
||||
const pluginSources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
@@ -79,6 +88,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const global = yield* Global.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const image = yield* Image.Service
|
||||
const instructionDiscovery = yield* InstructionDiscovery.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -95,7 +105,9 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* Skill.Service
|
||||
const skillDiscovery = yield* SkillDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
Context.make(Agent.Service, agent),
|
||||
@@ -103,6 +115,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Command.Service, command),
|
||||
Context.make(Config.Service, config),
|
||||
Context.make(Credential.Service, credential),
|
||||
Context.make(ConfigPluginSource.Service, pluginSources),
|
||||
Context.make(Bus.Service, bus),
|
||||
Context.make(Environment.Service, environment),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
@@ -112,6 +125,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Global.Service, global),
|
||||
Context.make(HttpClient.HttpClient, http),
|
||||
Context.make(Image.Service, image),
|
||||
Context.make(InstructionDiscovery.Service, instructionDiscovery),
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(KV.Service, kv),
|
||||
Context.make(Location.Service, location),
|
||||
@@ -128,7 +142,9 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(Skill.Service, skill),
|
||||
Context.make(SkillDiscovery.Service, skillDiscovery),
|
||||
Context.make(Tool.Service, tools),
|
||||
Context.make(Watcher.Service, watcher),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
})
|
||||
@@ -137,11 +153,51 @@ type ContextServices<A> = A extends Context.Context<infer R> ? R : never
|
||||
|
||||
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
|
||||
|
||||
export const requirements = LayerNode.group([
|
||||
Agent.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
InstructionDiscovery.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
])
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
WellKnownPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
CommandPlugin.Plugin,
|
||||
SkillPlugin.Plugin,
|
||||
...SystemPromptPlugin.Plugins,
|
||||
@@ -164,6 +220,7 @@ const pre = [
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
const post = [
|
||||
ConfigInstructionPlugin.Plugin,
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
|
||||
@@ -36,6 +36,7 @@ export const ModelsDevPlugin = define({
|
||||
draft.integrationID = Integration.ID.make(provider.info.id)
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export * as PlanPlugin from "./plan"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
import { SessionEvent } from "../session/event"
|
||||
|
||||
const plan = Agent.ID.make("plan")
|
||||
|
||||
const enter = `<system-reminder>
|
||||
You are in Plan mode. You are not allowed to edit or create files, and you may not ask a subagent to do that either.
|
||||
|
||||
You are in Plan mode until the user switches agents. Plan mode is not changed by user intent, tone, or imperative language. If the user asks you to change files, do not edit. Tell them they need to switch agents.
|
||||
</system-reminder>`
|
||||
|
||||
const leave = `<system-reminder>
|
||||
You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.
|
||||
</system-reminder>`
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.plan",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(plan, (item) => {
|
||||
item.name = Agent.Name.make("Plan")
|
||||
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
|
||||
item.mode = "primary"
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.tool.hook("execute.before", (event) => {
|
||||
if (event.agent !== plan) return Effect.void
|
||||
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
|
||||
return new ToolFailure({
|
||||
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter(
|
||||
(event): event is SessionEvent.Created | SessionEvent.AgentSelected =>
|
||||
event.type === "session.created" || event.type === "session.agent.selected",
|
||||
),
|
||||
Stream.runForEach((event) => {
|
||||
const text = reminder(event)
|
||||
if (!text) return Effect.void
|
||||
return ctx.session
|
||||
.synthetic({
|
||||
sessionID: event.data.sessionID,
|
||||
text,
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
|
||||
if (event.type === "session.created") {
|
||||
if (event.data.agent !== plan) return
|
||||
return enter
|
||||
}
|
||||
if (event.data.agent === event.data.previous) return
|
||||
if (event.data.agent === plan) return enter
|
||||
if (event.data.previous === plan) return leave
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { createProviderPlugin } from "./factory"
|
||||
|
||||
export const AlibabaPlugin = define({
|
||||
export const AlibabaPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.alibaba",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/alibaba") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
|
||||
evt.sdk = mod.createAlibaba(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
package: "@ai-sdk/alibaba",
|
||||
load: async (options) => {
|
||||
const { createAlibaba } = await import("@ai-sdk/alibaba")
|
||||
return createAlibaba(options)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { createProviderPlugin } from "./factory"
|
||||
|
||||
export const CoherePlugin = define({
|
||||
export const CoherePlugin = createProviderPlugin({
|
||||
id: "opencode.provider.cohere",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cohere") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
|
||||
evt.sdk = mod.createCohere(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
package: "@ai-sdk/cohere",
|
||||
load: async (options) => {
|
||||
const { createCohere } = await import("@ai-sdk/cohere")
|
||||
return createCohere(options)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { createProviderPlugin } from "./factory"
|
||||
|
||||
export const DeepInfraPlugin = define({
|
||||
export const DeepInfraPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.deepinfra",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/deepinfra") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
|
||||
evt.sdk = mod.createDeepInfra(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
package: "@ai-sdk/deepinfra",
|
||||
load: async (options) => {
|
||||
const { createDeepInfra } = await import("@ai-sdk/deepinfra")
|
||||
return createDeepInfra(options)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function createProviderPlugin(input: {
|
||||
readonly id: string
|
||||
readonly package: string
|
||||
readonly load: (options: AISDKHooks["sdk"]["options"]) => Promise<unknown>
|
||||
}) {
|
||||
return define({
|
||||
id: input.id,
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== input.package) return
|
||||
evt.sdk = yield* Effect.promise(() => input.load(evt.options))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { createProviderPlugin } from "./factory"
|
||||
|
||||
export const GatewayPlugin = define({
|
||||
export const GatewayPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/gateway") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
|
||||
evt.sdk = mod.createGateway(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
package: "@ai-sdk/gateway",
|
||||
load: async (options) => {
|
||||
const { createGateway } = await import("@ai-sdk/gateway")
|
||||
return createGateway(options)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { createProviderPlugin } from "./factory"
|
||||
|
||||
export const GroqPlugin = define({
|
||||
export const GroqPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.groq",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/groq") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
|
||||
evt.sdk = mod.createGroq(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
package: "@ai-sdk/groq",
|
||||
load: async (options) => {
|
||||
const { createGroq } = await import("@ai-sdk/groq")
|
||||
return createGroq(options)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { createProviderPlugin } from "./factory"
|
||||
|
||||
export const MistralPlugin = define({
|
||||
export const MistralPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.mistral",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/mistral") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
|
||||
evt.sdk = mod.createMistral(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
package: "@ai-sdk/mistral",
|
||||
load: async (options) => {
|
||||
const { createMistral } = await import("@ai-sdk/mistral")
|
||||
return createMistral(options)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { createProviderPlugin } from "./factory"
|
||||
|
||||
export const PerplexityPlugin = define({
|
||||
export const PerplexityPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.perplexity",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/perplexity") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
|
||||
evt.sdk = mod.createPerplexity(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
package: "@ai-sdk/perplexity",
|
||||
load: async (options) => {
|
||||
const { createPerplexity } = await import("@ai-sdk/perplexity")
|
||||
return createPerplexity(options)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { createProviderPlugin } from "./factory"
|
||||
|
||||
export const TogetherAIPlugin = define({
|
||||
export const TogetherAIPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.togetherai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/togetherai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
|
||||
evt.sdk = mod.createTogetherAI(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
package: "@ai-sdk/togetherai",
|
||||
load: async (options) => {
|
||||
const { createTogetherAI } = await import("@ai-sdk/togetherai")
|
||||
return createTogetherAI(options)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { createProviderPlugin } from "./factory"
|
||||
|
||||
export const VenicePlugin = define({
|
||||
export const VenicePlugin = createProviderPlugin({
|
||||
id: "opencode.provider.venice",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "venice-ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
|
||||
evt.sdk = mod.createVenice(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
package: "venice-ai-sdk-provider",
|
||||
load: async (options) => {
|
||||
const { createVenice } = await import("venice-ai-sdk-provider")
|
||||
return createVenice(options)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,12 +6,8 @@ import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { Skill } from "../skill"
|
||||
import { Config } from "../config"
|
||||
import { Location } from "../location"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigPluginSource } from "../config/plugin/source"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import opencodeContent from "./skill/opencode.md" with { type: "text" }
|
||||
import reportContent from "./skill/report.md" with { type: "text" }
|
||||
|
||||
@@ -28,29 +24,23 @@ export const Plugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const reportContent = yield* reportContentWithDiagnostics(ctx.app)
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
draft.source(
|
||||
Skill.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: Skill.Info.make({
|
||||
id: Skill.ID.make("opencode"),
|
||||
name: Skill.Name.make("OpenCode"),
|
||||
description: OpencodeDescription,
|
||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||
content: OpencodeContent,
|
||||
}),
|
||||
draft.add(
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("opencode"),
|
||||
name: Skill.Name.make("OpenCode"),
|
||||
description: OpencodeDescription,
|
||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||
content: OpencodeContent,
|
||||
}),
|
||||
)
|
||||
draft.source(
|
||||
Skill.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: Skill.Info.make({
|
||||
id: Skill.ID.make("report"),
|
||||
name: Skill.Name.make("Report"),
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
content: reportContent,
|
||||
}),
|
||||
draft.add(
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("report"),
|
||||
name: Skill.Name.make("Report"),
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
content: reportContent,
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -78,32 +68,10 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
|
||||
})
|
||||
|
||||
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") {
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
return Effect.succeed(
|
||||
(entry.info.plugins ?? []).map((item) => {
|
||||
const ref = typeof item === "string" ? { package: item } : item
|
||||
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
|
||||
if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package)
|
||||
return ref.package
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (entry.type !== "directory") return Effect.succeed([])
|
||||
return fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: entry.path,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
}).pipe(Effect.map((items) => items.flat().toSorted()))
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
return (yield* sources.operations())
|
||||
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
|
||||
.toSorted()
|
||||
})
|
||||
|
||||
function terminal() {
|
||||
|
||||
@@ -1,48 +1,17 @@
|
||||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Agent } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Command } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { Credential } from "../credential"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { Form } from "../form"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission"
|
||||
import { Plugin } from "../plugin"
|
||||
import { PluginPromise } from "../plugin/promise"
|
||||
import { Reference } from "../reference"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
import { Skill } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { Tool } from "../tool"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SdkPlugins } from "./sdk"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
@@ -63,65 +32,10 @@ const PluginModule = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
pre: readonly Plugin.Versioned[],
|
||||
post: readonly Plugin.Versioned[],
|
||||
operations: readonly Operation[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
@@ -168,7 +82,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
]
|
||||
})
|
||||
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
@@ -192,31 +108,6 @@ const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Op
|
||||
} satisfies Plugin.Versioned
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
|
||||
)
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
@@ -224,72 +115,29 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const config = yield* Config.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const ready = { current: yield* Deferred.make<void>() }
|
||||
let observed = 0
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
const operations = yield* sources.operations()
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
})
|
||||
const updates = Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||
),
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
).pipe(
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
@@ -315,48 +163,13 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
|
||||
const nodeDeps = [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
PluginInternal.requirements,
|
||||
] as const
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: nodeLayer,
|
||||
deps: [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
Agent.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
||||
export { layer }
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
|
||||
@@ -30,7 +30,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
draft.update("exa", (integration) => (integration.name = "Exa"))
|
||||
draft.method.update({
|
||||
integrationID: "exa",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
method: { type: "key" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "exa",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
method: { type: "key" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
|
||||
@@ -56,7 +56,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
draft.update("parallel", (integration) => (integration.name = "Parallel"))
|
||||
draft.method.update({
|
||||
integrationID: "parallel",
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
method: { type: "key" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "parallel",
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { Database } from "./database/database"
|
||||
@@ -13,7 +13,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
import { ProjectSchema } from "./project/schema"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
import { ProjectTable, upsertProject } from "./project/sql"
|
||||
|
||||
export const ID = ProjectSchema.ID
|
||||
export type ID = ProjectSchema.ID
|
||||
@@ -98,19 +98,7 @@ const layer = Layer.effect(
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = project.vcs?.type
|
||||
yield* tx
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
yield* upsertProject(tx, project)
|
||||
if (!project.vcs) return
|
||||
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
|
||||
if (project.directory === project.canonical) return
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { absoluteArrayColumn, absoluteColumn } from "../database/path"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { AbsolutePath } from "../schema"
|
||||
import { ProjectSchema } from "./schema"
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export const ProjectTable = sqliteTable("project", {
|
||||
id: text().$type<ProjectSchema.ID>().primaryKey(),
|
||||
worktree: absoluteColumn().notNull(),
|
||||
@@ -33,3 +39,22 @@ export const ProjectDirectoryTable = sqliteTable(
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
|
||||
)
|
||||
|
||||
export function upsertProject(
|
||||
db: DatabaseClient | Transaction,
|
||||
project: { readonly id: ProjectSchema.ID; readonly canonical: AbsolutePath; readonly vcs?: ProjectSchema.Vcs },
|
||||
) {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Bus } from "./bus"
|
||||
import { Location } from "./location"
|
||||
import { PtyID } from "./pty/schema"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { lazy } from "./util/lazy"
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
@@ -96,6 +97,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
@@ -165,7 +167,8 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
|
||||
const command =
|
||||
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
@@ -315,7 +318,11 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
)
|
||||
|
||||
export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] })
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node],
|
||||
})
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
@@ -34,6 +34,8 @@ export namespace RipgrepBinary {
|
||||
const fs = yield* FSUtil.Service
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const global = yield* Global.Service
|
||||
const findExecutable = (name: string) => which(name, undefined, global.bin)
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string, args: string[]) {
|
||||
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
|
||||
@@ -53,10 +55,12 @@ export namespace RipgrepBinary {
|
||||
config: (typeof PLATFORM)[keyof typeof PLATFORM],
|
||||
target: string,
|
||||
) {
|
||||
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
|
||||
const dir = yield* fs.makeTempDirectoryScoped({ directory: global.bin, prefix: "ripgrep-" })
|
||||
|
||||
if (config.extension === "zip") {
|
||||
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
|
||||
const shell =
|
||||
(yield* Effect.sync(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ??
|
||||
"powershell.exe"
|
||||
const result = yield* run(shell, [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
@@ -91,10 +95,10 @@ export namespace RipgrepBinary {
|
||||
return Service.of({
|
||||
filepath: yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
|
||||
const system = yield* Effect.sync(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg"))
|
||||
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
|
||||
|
||||
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
||||
const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
|
||||
|
||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
@@ -103,10 +107,10 @@ export namespace RipgrepBinary {
|
||||
|
||||
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
|
||||
const archive = path.join(Global.Path.bin, filename)
|
||||
const archive = path.join(global.bin, filename)
|
||||
|
||||
yield* Effect.logInfo("downloading ripgrep", { url })
|
||||
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
|
||||
yield* fs.ensureDir(global.bin).pipe(Effect.orDie)
|
||||
const bytes = yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.arrayBuffer),
|
||||
@@ -127,6 +131,6 @@ export namespace RipgrepBinary {
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
||||
deps: [FSUtil.node, Global.node, httpClient, CrossSpawnSpawner.node],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export * from "./session/schema"
|
||||
|
||||
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project"
|
||||
import { Workspace } from "./workspace"
|
||||
import { Model } from "./model"
|
||||
@@ -21,7 +21,7 @@ import { Agent } from "./agent"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app"
|
||||
import { Slug } from "./util/slug"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
import { upsertProject } from "./project/sql"
|
||||
import path from "path"
|
||||
import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
@@ -309,22 +309,7 @@ const layer = Layer.effect(
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const persistProject = (project: Project.Resolved) => {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
@@ -716,10 +701,11 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const session = yield* result.get(input.sessionID)
|
||||
yield* bus.publish(SessionEvent.AgentSelected, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
previous: session.agent,
|
||||
})
|
||||
}),
|
||||
switchModel: Effect.fn("Session.switchModel")(function* (input) {
|
||||
@@ -733,6 +719,7 @@ const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID: input.sessionID,
|
||||
model: input.model,
|
||||
previous: session.model,
|
||||
})
|
||||
}),
|
||||
rename: Effect.fn("Session.rename")(function* (input) {
|
||||
@@ -769,27 +756,22 @@ const layer = Layer.effect(
|
||||
{ location: current.location },
|
||||
)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
const inputID = input.id ?? SessionMessage.ID.create()
|
||||
const admitted = yield* SessionPending.admitCompaction(db, bus, {
|
||||
id: inputID,
|
||||
sessionID: input.sessionID,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionPending.LifecycleConflict
|
||||
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const inputID = input.id ?? SessionMessage.ID.create()
|
||||
const admitted = yield* SessionPending.admitCompaction(db, bus, {
|
||||
id: inputID,
|
||||
sessionID: input.sessionID,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionPending.LifecycleConflict
|
||||
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
wait: Effect.fn("Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
|
||||
@@ -56,16 +56,22 @@ export const layer = Layer.effect(
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
// Starting or finishing on its own clears stale suspension; interruption preserves it because
|
||||
// managed-server teardown suspends active Sessions immediately before interrupting their drains.
|
||||
const clearSuspensionOnCommit = (sessionID: SessionSchema.ID) => ({
|
||||
commit: () => Effect.asVoid(store.consumeSuspended(sessionID)),
|
||||
// Write-ahead claim: starting records the durable intent that a turn is in flight, in the same
|
||||
// transaction as the started event. Terminals release it — except shutdown interruption, which
|
||||
// preserves the claim so the next server start resumes the turn. A claim that survives with no
|
||||
// terminal is the signature of a process that died without teardown (crash, SIGKILL, eviction);
|
||||
// recovery is a property of the database, never of a shutdown hook that may not run.
|
||||
const claimOnCommit = (sessionID: SessionSchema.ID) => ({
|
||||
commit: () => store.claim(sessionID),
|
||||
})
|
||||
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
|
||||
commit: () => store.release(sessionID),
|
||||
})
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
started: (sessionID) =>
|
||||
reportLifecycle(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, clearSuspensionOnCommit(sessionID)),
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
),
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
@@ -86,11 +92,17 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const outcome = terminal(exit, reason)
|
||||
if (outcome.type === "succeeded") {
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, clearSuspensionOnCommit(sessionID))
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, releaseOnCommit(sessionID))
|
||||
return
|
||||
}
|
||||
if (outcome.type === "interrupted") {
|
||||
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
|
||||
// A user cancel (or a superseding execution) releases the claim: the turn must not
|
||||
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Interrupted,
|
||||
{ sessionID, reason: outcome.reason },
|
||||
outcome.reason === "shutdown" ? undefined : releaseOnCommit(sessionID),
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* bus.publish(
|
||||
@@ -99,7 +111,7 @@ export const layer = Layer.effect(
|
||||
sessionID,
|
||||
error: outcome.error,
|
||||
},
|
||||
clearSuspensionOnCommit(sessionID),
|
||||
releaseOnCommit(sessionID),
|
||||
)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -5,61 +5,111 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../../bus"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
|
||||
const CONTINUE_AFTER_SERVER_RESTART =
|
||||
"The server restarted while you were working. Continue from where you left off without repeating completed work."
|
||||
|
||||
const RESUME_EXHAUSTED = {
|
||||
type: "aborted",
|
||||
message: "Execution was interrupted repeatedly and will not be resumed automatically.",
|
||||
} as const
|
||||
|
||||
export interface Options {
|
||||
/**
|
||||
* Times a single turn may be resumed before it is terminalized instead.
|
||||
* The counter is durable and only a terminal event resets it, so a turn
|
||||
* that keeps dying cannot crash-loop across restarts. Turns that complete
|
||||
* never accumulate: the budget is per-turn, not per-session.
|
||||
*/
|
||||
readonly maxAttempts?: number
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ATTEMPTS = 10
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Marks every execution active in this process for resumption by the next server start.
|
||||
* Call once new work has stopped arriving and before teardown interrupts the drains.
|
||||
* Resumes Sessions whose execution claim was never released — turns orphaned
|
||||
* by a process that died without teardown, or interrupted by a graceful
|
||||
* shutdown (which preserves the claim on purpose). The claim is never
|
||||
* cleared here: only a terminal event releases it, so a death anywhere in
|
||||
* the resume path leaves the same orphaned claim for the next boot.
|
||||
*/
|
||||
readonly suspendActiveSessions: Effect.Effect<void>
|
||||
/** Resumes suspended Sessions. Each suspension is consumed atomically, so a Session resumes at most once. */
|
||||
readonly resumeSuspendedSessions: Effect.Effect<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart continuity actions for the managed server. The service is inert until called: only the
|
||||
* managed server invokes it, so default, embedded, and stdio servers never suspend or auto-resume.
|
||||
* Recovery for orphaned executions. Claims are written at turn start by
|
||||
* SessionExecution, so this sweep needs no cooperation from the previous
|
||||
* process: crash, SIGKILL, isolate eviction, and graceful restart all leave
|
||||
* the same durable signature.
|
||||
*
|
||||
* The sweep assumes every orphaned claim's owner is dead. The managed-server
|
||||
* protocol guarantees this: a successor is only spawned after the previous
|
||||
* process is confirmed dead (client service `kill`/`evict` poll the PID), the
|
||||
* registration lock admits one managed server at a time, and unregistered
|
||||
* servers sharing the database never sweep. The service is inert until called
|
||||
* — the managed server invokes it at boot; embedders may call it from their
|
||||
* own start-up.
|
||||
*/
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRestart") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
return Service.of({
|
||||
suspendActiveSessions: Effect.gen(function* () {
|
||||
yield* store.suspend(yield* execution.active)
|
||||
}),
|
||||
resumeSuspendedSessions: Effect.gen(function* () {
|
||||
const sessions = yield* store.listSuspended()
|
||||
yield* Effect.forEach(
|
||||
sessions,
|
||||
(sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* store.consumeSuspended(sessionID))) return
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
||||
description: "Continuing after restart",
|
||||
})
|
||||
// Drain failures are already logged and durably recorded by the execution layer.
|
||||
yield* Effect.ignore(execution.resume(sessionID))
|
||||
}),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Effect.scope
|
||||
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
||||
|
||||
const resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
// Durable before the resume runs, so a crash inside the resumed turn is
|
||||
// counted by the next sweep and the budget cannot be dodged.
|
||||
const attempts = yield* store.countResume(sessionID)
|
||||
if (attempts === undefined) return // the Session was deleted since listing
|
||||
if (attempts > maxAttempts) {
|
||||
// Terminalize instead: the release hook clears the claim and resets the
|
||||
// counter atomically with the terminal event.
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Failed,
|
||||
{ sessionID, error: RESUME_EXHAUSTED },
|
||||
{ commit: () => store.release(sessionID) },
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
||||
description: "Continuing after restart",
|
||||
})
|
||||
// Forked into the service scope so boot never waits on resumed turns;
|
||||
// resuming an already-live Session joins its execution. Drain failures
|
||||
// are logged and durably recorded by the execution layer.
|
||||
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
resumeSuspendedSessions: Effect.gen(function* () {
|
||||
// Child claims never drive recovery (children are not resumed), so a
|
||||
// dead child's claim is noise no terminal will ever release. Clearing
|
||||
// is safe even against a live child: claims are recovery markers, not
|
||||
// locks, and children are excluded from that recovery.
|
||||
yield* store.releaseChildClaims
|
||||
const active = yield* execution.active
|
||||
// Sessions already draining in this process keep their claim; resuming
|
||||
// them would only inject a stray continuation into a live turn.
|
||||
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
|
||||
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
layer: layer(),
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { InstructionState } from "./instruction-state"
|
||||
@@ -10,8 +11,6 @@ import { SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
@@ -50,7 +49,7 @@ const messageRows = Effect.fnUntraced(function* (
|
||||
})
|
||||
|
||||
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
SessionMessageRow.decode(row).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export * as SessionMessageRow from "./message-row"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { SessionMessage } from "./message"
|
||||
import type { SessionMessageTable } from "./sql"
|
||||
|
||||
export type Representation = Pick<typeof SessionMessageTable.$inferSelect, "id" | "type" | "data">
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const decodeMessageSync = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
export const decode = (row: Representation) => decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
|
||||
export const decodeSync = (row: Representation) => decodeMessageSync({ ...row.data, id: row.id, type: row.type })
|
||||
|
||||
export function encode(message: SessionMessage.Info): Representation {
|
||||
const { id, type, ...data } = encodeMessage(message)
|
||||
return { id: SessionMessage.ID.make(id), type, data }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
@@ -59,19 +60,23 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.created": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const previous = event.data.previous ?? (yield* adapter.getAgent())
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.model.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const previous = yield* adapter.getModel()
|
||||
const previous = event.data.previous ?? (yield* adapter.getModel())
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.ModelSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Bus } from "../bus"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable, SessionPendingTable } from "./sql"
|
||||
|
||||
@@ -35,7 +36,6 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
|
||||
const encodeUser = Schema.encodeSync(UserData)
|
||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
|
||||
|
||||
@@ -113,7 +113,7 @@ const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(func
|
||||
if (row === undefined) return undefined
|
||||
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const message = SessionMessageRow.decodeSync(row)
|
||||
const base = { id, sessionID, timeCreated: message.time.created, delivery }
|
||||
if (message.type === "user")
|
||||
return User.make({
|
||||
|
||||
@@ -5,9 +5,11 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "../agent"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionPending } from "./pending"
|
||||
import { Workspace } from "../workspace"
|
||||
@@ -21,9 +23,6 @@ type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
type Usage = {
|
||||
@@ -209,27 +208,31 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
|
||||
function run(db: DatabaseService, event: MessageEvent) {
|
||||
return Effect.gen(function* () {
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => SessionMessageRow.decodeSync(row)
|
||||
const updateMessage = (message: SessionMessage.Info) => {
|
||||
if (event.durable === undefined)
|
||||
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
const row = SessionMessageRow.encode(message)
|
||||
return db
|
||||
.update(SessionMessageTable)
|
||||
.set({ type, time_created: DateTime.toEpochMillis(message.time.created), data })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, SessionMessage.ID.make(id)),
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
),
|
||||
)
|
||||
.set({ type: row.type, time_created: DateTime.toEpochMillis(message.time.created), data: row.data })
|
||||
.where(and(eq(SessionMessageTable.id, row.id), eq(SessionMessageTable.session_id, event.data.sessionID)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getAgent() {
|
||||
return db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
|
||||
)
|
||||
},
|
||||
getModel() {
|
||||
return db
|
||||
.select({ model: SessionTable.model })
|
||||
@@ -331,17 +334,16 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
|
||||
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
const row = SessionMessageRow.encode(message)
|
||||
return db
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(id),
|
||||
id: row.id,
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
type: row.type,
|
||||
seq: event.durable.seq,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
data: row.data,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -398,12 +400,15 @@ const layer = Layer.effectDiscard(
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.ModelSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RelativePath } from "../schema"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable } from "./sql"
|
||||
|
||||
@@ -46,10 +47,9 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
const message = yield* SessionMessageRow.decode(row).pipe(Effect.orDie)
|
||||
if (message.type !== "assistant" || !message.snapshot?.start) continue
|
||||
for (const file of message.snapshot.files ?? [])
|
||||
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
|
||||
|
||||
@@ -59,6 +59,25 @@ const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
||||
return []
|
||||
}
|
||||
|
||||
const userAttachmentContent = (files: readonly FileAttachment[]) => {
|
||||
const eligible = files.filter(
|
||||
(file) => imageMimes.has(file.mime) && file.source.type === "inline" && file.mention?.text,
|
||||
)
|
||||
if (eligible.length < 2) return files.flatMap(attachmentContent)
|
||||
|
||||
const seen = new Map<string, Set<string>>()
|
||||
return files.flatMap((file) => {
|
||||
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
|
||||
return attachmentContent(file)
|
||||
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
|
||||
const payloads = seen.get(metadata) ?? new Set<string>()
|
||||
if (payloads.has(file.data)) return []
|
||||
payloads.add(file.data)
|
||||
seen.set(metadata, payloads)
|
||||
return attachmentContent(file)
|
||||
})
|
||||
}
|
||||
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
const providerMetadata = (
|
||||
@@ -186,7 +205,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
const content = [
|
||||
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
|
||||
...(message.text === "" ? [] : [Message.text(message.text)]),
|
||||
...(message.files ?? []).flatMap(attachmentContent),
|
||||
...userAttachmentContent(message.files ?? []),
|
||||
]
|
||||
if (content.length === 0) return []
|
||||
return [
|
||||
|
||||
@@ -58,7 +58,9 @@ export const SessionTable = sqliteTable(
|
||||
...Timestamps,
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
|
||||
time_suspended: integer(),
|
||||
resume_attempts: integer().notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
index("session_v2_project_idx").on(table.project_id),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export * as SessionStore from "./store"
|
||||
|
||||
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionHistory } from "./history"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
import { fromRow } from "./info"
|
||||
@@ -17,10 +18,32 @@ export interface Interface {
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
|
||||
/**
|
||||
* Top-level Sessions holding an execution claim. Child (subagent) Sessions
|
||||
* are excluded: a resumed parent re-runs its tool call and spawns fresh
|
||||
* children, so resuming orphaned children would duplicate their work.
|
||||
*/
|
||||
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
|
||||
/** Clears suspension, reporting whether this caller consumed it. At most one concurrent caller receives true. */
|
||||
readonly consumeSuspended: (sessionID: Session.ID) => Effect.Effect<boolean>
|
||||
readonly suspend: (sessionIDs: Iterable<Session.ID>) => Effect.Effect<void>
|
||||
/**
|
||||
* Records the execution claim: the durable write-ahead intent that a turn is
|
||||
* (or was) in flight. Set when execution starts; a claim that survives to the
|
||||
* next boot marks a turn that never completed — its process crashed or shut
|
||||
* down mid-turn.
|
||||
*/
|
||||
readonly claim: (sessionID: Session.ID) => Effect.Effect<void>
|
||||
/** Releases the claim and resets resume accounting. Terminal events call this on commit. */
|
||||
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
|
||||
/**
|
||||
* Clears orphaned child (subagent) claims. Children are never resumed
|
||||
* independently, so a dead child's claim is noise no terminal will ever
|
||||
* release.
|
||||
*/
|
||||
readonly releaseChildClaims: Effect.Effect<void>
|
||||
/**
|
||||
* Durably counts one more resume of an orphaned claim, returning the new
|
||||
* total — or undefined when the Session no longer exists.
|
||||
*/
|
||||
readonly countResume: (sessionID: Session.ID) => Effect.Effect<number | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStore") {}
|
||||
@@ -29,8 +52,6 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
@@ -49,7 +70,7 @@ const layer = Layer.effect(
|
||||
return row
|
||||
? {
|
||||
sessionID: Session.ID.make(row.session_id),
|
||||
message: yield* decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie),
|
||||
message: yield* SessionMessageRow.decode(row).pipe(Effect.orDie),
|
||||
}
|
||||
: undefined
|
||||
}),
|
||||
@@ -57,35 +78,52 @@ const layer = Layer.effect(
|
||||
return yield* db
|
||||
.select({ sessionID: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(isNotNull(SessionTable.time_suspended))
|
||||
.where(and(isNotNull(SessionTable.time_suspended), isNull(SessionTable.parent_id)))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.map((row) => row.sessionID)),
|
||||
)
|
||||
}),
|
||||
consumeSuspended: Effect.fn("SessionStore.consumeSuspended")(function* (sessionID) {
|
||||
return (
|
||||
(yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null })
|
||||
.where(and(eq(SessionTable.id, sessionID), isNotNull(SessionTable.time_suspended)))
|
||||
.returning({ sessionID: SessionTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
}),
|
||||
suspend: Effect.fn("SessionStore.suspend")(function* (sessionIDs) {
|
||||
const ids = Array.from(sessionIDs)
|
||||
if (ids.length === 0) return
|
||||
// The null guard preserves the original suspension time if a Session is somehow suspended twice.
|
||||
claim: Effect.fn("SessionStore.claim")(function* (sessionID) {
|
||||
// The null guard makes re-claiming a still-claimed Session a zero-row
|
||||
// no-op (a resumed turn re-claims through the same started hook).
|
||||
// Claim bookkeeping never counts as user activity: time_updated is
|
||||
// pinned so session ordering only moves on real changes.
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: Date.now() })
|
||||
.where(and(inArray(SessionTable.id, ids), isNull(SessionTable.time_suspended)))
|
||||
.set({ time_suspended: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.time_suspended)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
release: Effect.fn("SessionStore.release")(function* (sessionID) {
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
releaseChildClaims: db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(isNotNull(SessionTable.time_suspended), isNotNull(SessionTable.parent_id)))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.withSpan("SessionStore.releaseChildClaims")),
|
||||
countResume: Effect.fn("SessionStore.countResume")(function* (sessionID) {
|
||||
const row = yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
resume_attempts: sql`${SessionTable.resume_attempts} + 1`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.returning({ attempts: SessionTable.resume_attempts })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.attempts
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionTransfer from "./transfer"
|
||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -12,12 +12,13 @@ import { Bus } from "../bus"
|
||||
import { Database } from "../database/database"
|
||||
import { Location } from "../location"
|
||||
import { Project } from "../project"
|
||||
import { ProjectTable } from "../project/sql"
|
||||
import { upsertProject } from "../project/sql"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
import { Session } from "../session"
|
||||
import { Slug } from "../util/slug"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageRow } from "./message-row"
|
||||
import { SessionProjector } from "./projector"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
|
||||
@@ -47,24 +48,7 @@ const layer = Layer.effect(
|
||||
const { db } = yield* Database.Service
|
||||
const projects = yield* Project.Service
|
||||
const sessions = yield* Session.Service
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
const persistProject = (project: Project.Resolved) => {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
|
||||
return Service.of({
|
||||
export: Effect.fn("SessionTransfer.export")(function* (input) {
|
||||
@@ -86,15 +70,14 @@ const layer = Layer.effect(
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* persistProject(project)
|
||||
const messages = input.data.messages.map((message, index) => {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id: _, type, ...data } = encoded
|
||||
const row = SessionMessageRow.encode(message)
|
||||
return {
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type,
|
||||
type: row.type,
|
||||
seq: index + 1,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
data: row.data,
|
||||
}
|
||||
})
|
||||
yield* bus
|
||||
|
||||
@@ -143,7 +143,9 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
|
||||
@@ -34,15 +34,19 @@ function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
function full(file: string, options?: Options) {
|
||||
function findExecutable(name: string, bin?: string) {
|
||||
return which(name, undefined, bin)
|
||||
}
|
||||
|
||||
function full(file: string, options?: Options, bin?: string) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = FSUtil.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options, bin) || shell
|
||||
return shell
|
||||
}
|
||||
if (name(shell) === "bash") return gitbash(options) || which(shell) || shell
|
||||
return which(shell) || shell
|
||||
if (name(shell) === "bash") return gitbash(options, bin) || findExecutable(shell, bin) || shell
|
||||
return findExecutable(shell, bin) || shell
|
||||
}
|
||||
|
||||
function meta(file: string) {
|
||||
@@ -57,21 +61,26 @@ function rooted(file: string) {
|
||||
return path.isAbsolute(FSUtil.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string, options?: Options) {
|
||||
const shell = full(file, options)
|
||||
function resolve(file: string, options?: Options, bin?: string) {
|
||||
const shell = full(file, options, bin)
|
||||
if (rooted(shell)) {
|
||||
if (stat(shell)?.isFile()) return shell
|
||||
return
|
||||
}
|
||||
return which(shell) ?? undefined
|
||||
return findExecutable(shell, bin) ?? undefined
|
||||
}
|
||||
|
||||
function win(options?: Options) {
|
||||
function win(options?: Options, bin?: string) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"]
|
||||
[
|
||||
findExecutable("pwsh", bin),
|
||||
findExecutable("powershell", bin),
|
||||
gitbash(options, bin),
|
||||
process.env.COMSPEC || "cmd.exe",
|
||||
]
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.map((file) => full(file, options)),
|
||||
.map((file) => full(file, options, bin)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -82,27 +91,27 @@ async function unix() {
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
|
||||
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) {
|
||||
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
|
||||
if (file && (!opts?.acceptable || ok(file))) {
|
||||
const shell = resolve(file, options)
|
||||
const shell = resolve(file, options, bin)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win(options)[0]
|
||||
return fallback()
|
||||
if (process.platform === "win32") return win(options, bin)[0]
|
||||
return fallback(bin)
|
||||
}
|
||||
|
||||
export function gitbash(options?: Options) {
|
||||
export function gitbash(options?: Options, bin?: string) {
|
||||
if (process.platform !== "win32") return
|
||||
if (options?.gitbash) return options.gitbash
|
||||
const git = which("git")
|
||||
const git = findExecutable("git", bin)
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
function fallback(bin?: string) {
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = which("bash")
|
||||
const bash = findExecutable("bash", bin)
|
||||
if (bash) return bash
|
||||
return "/bin/sh"
|
||||
}
|
||||
@@ -120,12 +129,12 @@ export function ps(file: string) {
|
||||
return meta(file)?.ps === true
|
||||
}
|
||||
|
||||
function info(file: string, options?: Options): Item {
|
||||
const item = full(file, options)
|
||||
function info(file: string, options?: Options, bin?: string): Item {
|
||||
const item = full(file, options, bin)
|
||||
const n = name(item)
|
||||
return {
|
||||
path: item,
|
||||
name: resolve(n, options) ? n : item,
|
||||
name: resolve(n, options, bin) ? n : item,
|
||||
acceptable: ok(item),
|
||||
}
|
||||
}
|
||||
@@ -139,30 +148,36 @@ export function args(file: string, command: string) {
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
let defaultPreferred: string | undefined
|
||||
let defaultAcceptable: string | undefined
|
||||
let defaultPreferred: { bin?: string; value: string } | undefined
|
||||
let defaultAcceptable: { bin?: string; value: string } | undefined
|
||||
|
||||
export function preferred(configShell?: string, options?: Options) {
|
||||
if (configShell) return select(configShell, options)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options)
|
||||
defaultPreferred ??= select(process.env.SHELL)
|
||||
return defaultPreferred
|
||||
export function preferred(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, undefined, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
|
||||
const cached = defaultPreferred
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
|
||||
defaultPreferred = { bin, value }
|
||||
return value
|
||||
}
|
||||
preferred.reset = () => {
|
||||
defaultPreferred = undefined
|
||||
}
|
||||
|
||||
export function acceptable(configShell?: string, options?: Options) {
|
||||
if (configShell) return select(configShell, options, { acceptable: true })
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true })
|
||||
defaultAcceptable ??= select(process.env.SHELL, undefined, { acceptable: true })
|
||||
return defaultAcceptable
|
||||
export function acceptable(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, { acceptable: true }, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
|
||||
const cached = defaultAcceptable
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
|
||||
defaultAcceptable = { bin, value }
|
||||
return value
|
||||
}
|
||||
acceptable.reset = () => {
|
||||
defaultAcceptable = undefined
|
||||
}
|
||||
|
||||
export async function list(options?: Options): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options))
|
||||
export async function list(options?: Options, bin?: string): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options, bin) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
}
|
||||
|
||||
+23
-192
@@ -2,17 +2,12 @@ export * as Skill from "./skill"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Agent } from "./agent"
|
||||
import { ConfigMarkdown } from "./config/markdown"
|
||||
import { Bus } from "./bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Permission } from "./permission"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SkillDiscovery } from "./skill/discovery"
|
||||
import { State } from "./state"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
|
||||
export const DirectorySource = Skill.DirectorySource
|
||||
export type DirectorySource = Skill.DirectorySource
|
||||
@@ -57,38 +52,18 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
slash: Schema.Boolean.pipe(Schema.optional),
|
||||
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
|
||||
|
||||
const metadataBoolean = (metadata: unknown, key: string) => {
|
||||
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined
|
||||
}
|
||||
const value = (metadata as { readonly [key: string]: unknown })[key]
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value !== "string") return undefined
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === "true") return true
|
||||
if (normalized === "false") return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
sources: Types.DeepMutable<Source>[]
|
||||
skills: Map<ID, Types.DeepMutable<Info>>
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
source: (source: Source) => void
|
||||
list: () => readonly Source[]
|
||||
list: () => readonly Types.DeepMutable<Info>[]
|
||||
add: (skill: Info) => void
|
||||
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (id: string) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly sources: () => Effect.Effect<Source[]>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
@@ -97,179 +72,35 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sk
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* SkillDiscovery.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const changes = yield* PubSub.unbounded<string>()
|
||||
|
||||
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
|
||||
const changed = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
|
||||
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
|
||||
)
|
||||
if (invalidated.length === 0) return false
|
||||
cache.clear()
|
||||
yield* FiberMap.clear(watches)
|
||||
yield* Effect.logInfo("skill cache invalidated", {
|
||||
file,
|
||||
sources: invalidated.map(([key]) => key),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (!changed) return
|
||||
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe(
|
||||
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
|
||||
)
|
||||
yield* FiberMap.run(
|
||||
watches,
|
||||
`${type}:${target}`,
|
||||
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
|
||||
{
|
||||
onlyIfMissing: true,
|
||||
startImmediately: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function firstMissing(target: string): Effect.Effect<string | undefined> {
|
||||
const parent = path.dirname(target)
|
||||
if (parent === target) return Effect.succeed(undefined)
|
||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
||||
}
|
||||
|
||||
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
|
||||
directory: string,
|
||||
) {
|
||||
const target = path.resolve(directory)
|
||||
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (resolved) {
|
||||
yield* watch(resolved, "directory")
|
||||
if (resolved !== target) {
|
||||
yield* watch(target, "file")
|
||||
}
|
||||
return resolved === target ? [target] : [target, resolved]
|
||||
}
|
||||
const missing = yield* firstMissing(target)
|
||||
if (missing) yield* watch(missing, "file")
|
||||
if (
|
||||
yield* fs.realPath(directory).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
) {
|
||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
||||
return yield* watchDirectory(directory)
|
||||
}
|
||||
return [target]
|
||||
})
|
||||
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "skill",
|
||||
initial: () => ({ sources: [] }),
|
||||
initial: () => ({ skills: new Map() }),
|
||||
draft: (draft) => ({
|
||||
source: (source) => {
|
||||
if (draft.sources.some((item) => Source.equals(item, source))) return
|
||||
draft.sources.push(source as Types.DeepMutable<Source>)
|
||||
list: () => Array.from(draft.skills.values()),
|
||||
add: (skill) => {
|
||||
draft.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
|
||||
},
|
||||
update: (id, update) => {
|
||||
const current = draft.skills.get(ID.make(id))
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.id = ID.make(id)
|
||||
},
|
||||
remove: (id) => {
|
||||
draft.skills.delete(ID.make(id))
|
||||
},
|
||||
list: () => draft.sources as Source[],
|
||||
}),
|
||||
finalize: () =>
|
||||
lock
|
||||
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
|
||||
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
|
||||
})
|
||||
|
||||
const load = Effect.fn("Skill.load")(function* (source: Source) {
|
||||
const skills: Info[] = []
|
||||
if (source.type === "embedded") {
|
||||
yield* Effect.logDebug("skill source loaded", {
|
||||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories: [],
|
||||
skills: [source.skill.id],
|
||||
})
|
||||
return { skills: [source.skill], paths: [] }
|
||||
}
|
||||
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
|
||||
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
|
||||
const paths = [...roots]
|
||||
for (const directory of directories) {
|
||||
const files = yield* fs
|
||||
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
for (const filepath of files.toSorted()) {
|
||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
|
||||
const external = path.dirname(resolved)
|
||||
paths.push(external)
|
||||
yield* watch(external, "directory")
|
||||
}
|
||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!content) continue
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) continue
|
||||
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
|
||||
if (!frontmatter) continue
|
||||
const id =
|
||||
path.dirname(filepath) === directory
|
||||
? path.basename(filepath, ".md")
|
||||
: path.basename(path.dirname(filepath))
|
||||
skills.push({
|
||||
id: ID.make(id),
|
||||
name: Name.make(frontmatter.name ?? id),
|
||||
description: frontmatter.description,
|
||||
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
|
||||
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
|
||||
location: AbsolutePath.make(filepath),
|
||||
content: markdown.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
yield* Effect.logDebug("skill source loaded", {
|
||||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.id),
|
||||
})
|
||||
return { skills, paths }
|
||||
})
|
||||
|
||||
const list = Effect.fn("Skill.list")(function* () {
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const skills = new Map<ID, Info>()
|
||||
for (const source of state.get().sources) {
|
||||
const key = Source.key(source)
|
||||
const loaded = cache.get(key) ?? (yield* load(source))
|
||||
cache.set(key, loaded)
|
||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||
}
|
||||
return Array.from(skills.values())
|
||||
}),
|
||||
)
|
||||
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
sources: Effect.fn("Skill.sources")(function* () {
|
||||
return state.get().sources
|
||||
list: Effect.fn("Skill.list")(function* () {
|
||||
return Array.from(state.get().skills.values())
|
||||
}),
|
||||
list,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -277,5 +108,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
|
||||
deps: [Bus.node],
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ Usage notes:
|
||||
- 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 Input = Schema.Struct({
|
||||
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
|
||||
questions: Schema.Array(Question.Prompt).check(Schema.isNonEmpty()).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import whichPkg from "which"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv, bin?: string) {
|
||||
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
|
||||
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
|
||||
const full = base && bin ? base + path.delimiter + bin : base || bin
|
||||
const result = whichPkg.sync(cmd, {
|
||||
nothrow: true,
|
||||
path: full,
|
||||
|
||||
@@ -165,7 +165,6 @@ describe("Agent", () => {
|
||||
"compaction",
|
||||
"explore",
|
||||
"general",
|
||||
"plan",
|
||||
"summary",
|
||||
"title",
|
||||
])
|
||||
|
||||
@@ -275,6 +275,73 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves tool result content in AI SDK prompts", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
|
||||
mime: "application/pdf",
|
||||
name: "document.pdf",
|
||||
},
|
||||
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
|
||||
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
|
||||
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
output: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
|
||||
{
|
||||
type: "file-data",
|
||||
data: "JVBERg==",
|
||||
mediaType: "application/pdf",
|
||||
filename: "document.pdf",
|
||||
},
|
||||
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
|
||||
{ type: "image-url", url: "https://example.com/pixel.png" },
|
||||
{ type: "file-url", url: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: first
|
||||
description: First skill
|
||||
---
|
||||
|
||||
# first
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: second
|
||||
description: Second skill
|
||||
---
|
||||
|
||||
# second
|
||||
@@ -5,8 +5,10 @@ import { describe, expect } from "bun:test"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
@@ -19,10 +21,19 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Fiber, Logger, Stream } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const staticIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[ConfigPluginSource.node, ConfigPluginSource.empty],
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
@@ -157,6 +168,98 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads auto-discovered plugin package entrypoints in order", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("package-exports")
|
||||
expect(ids).toContain("package-module")
|
||||
expect(ids).toContain("package-main")
|
||||
expect(ids).toContain("package-index")
|
||||
}),
|
||||
false,
|
||||
async (directory) => {
|
||||
await Promise.all([
|
||||
writeDiscoveredPackage(directory, "exports", { exports: "./entry.ts" }, { "entry.ts": "package-exports" }),
|
||||
writeDiscoveredPackage(
|
||||
directory,
|
||||
"module",
|
||||
{ exports: "./missing.js", module: "./entry.js" },
|
||||
{ "entry.js": "package-module" },
|
||||
),
|
||||
writeDiscoveredPackage(
|
||||
directory,
|
||||
"main",
|
||||
{ exports: { import: "./missing.js" }, module: "./missing.js", main: "./entry.js" },
|
||||
{ "entry.js": "package-main" },
|
||||
),
|
||||
writeDiscoveredPackage(directory, "index", undefined, { "index.js": "package-index" }),
|
||||
])
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps auto-discovered package entrypoints inside the package directory", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("contained-fallback")
|
||||
expect(ids).toContain("symlink-fallback")
|
||||
expect(ids).not.toContain("escaped-entrypoint")
|
||||
}),
|
||||
false,
|
||||
async (directory) => {
|
||||
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
|
||||
await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint"))
|
||||
await writeDiscoveredPackage(
|
||||
directory,
|
||||
"contained",
|
||||
{ exports: "../../escape.js" },
|
||||
{ "index.js": "contained-fallback" },
|
||||
)
|
||||
await writeDiscoveredPackage(
|
||||
directory,
|
||||
"symlink",
|
||||
{ exports: "./entry.js" },
|
||||
{ "index.js": "symlink-fallback" },
|
||||
)
|
||||
await fs.symlink(
|
||||
path.join(directory, ".opencode", "escape.js"),
|
||||
path.join(directory, ".opencode", "plugins", "symlink", "entry.js"),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("opencode.agent")
|
||||
expect(ids).toContain("static-sdk")
|
||||
expect(ids).not.toContain("config-promise-plugin")
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
true,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reloads an auto-discovered plugin when its file changes", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
@@ -355,3 +458,21 @@ export default Plugin.define({
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
function discoveredPlugin(id: string) {
|
||||
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
|
||||
}
|
||||
|
||||
async function writeDiscoveredPackage(
|
||||
directory: string,
|
||||
name: string,
|
||||
manifest: Record<string, unknown> | undefined,
|
||||
files: Record<string, string>,
|
||||
) {
|
||||
const plugin = path.join(directory, ".opencode", "plugins", name)
|
||||
await fs.mkdir(plugin, { recursive: true })
|
||||
await Promise.all([
|
||||
...(manifest ? [fs.writeFile(path.join(plugin, "package.json"), JSON.stringify(manifest))] : []),
|
||||
...Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -46,9 +46,7 @@ describe("config plugin reloads", () => {
|
||||
|
||||
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
|
||||
expect((yield* commands.get("first"))?.description).toBe("First command")
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(true)
|
||||
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(true)
|
||||
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
|
||||
|
||||
@@ -69,12 +67,8 @@ describe("config plugin reloads", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(false)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
|
||||
).toBe(true)
|
||||
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(false)
|
||||
expect((yield* skills.list()).some((skill) => skill.id === "second")).toBe(true)
|
||||
}).pipe(
|
||||
Effect.provide(Config.testLayer([config("first")])),
|
||||
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
|
||||
@@ -89,7 +83,7 @@ function config(name: string) {
|
||||
info: decode({
|
||||
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
|
||||
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
|
||||
skills: [`/skills/${name}`],
|
||||
skills: [path.join(import.meta.dir, "fixture", "skills", `${name}-source`)],
|
||||
references: { [name]: `/references/${name}` },
|
||||
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
|
||||
}),
|
||||
|
||||
@@ -1,87 +1,379 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory as ConfigDirectory,
|
||||
Document,
|
||||
type Entry,
|
||||
Info,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const urls = new Map<string, AbsolutePath[]>()
|
||||
const failedUrls = new Set<string>()
|
||||
let pulls = 0
|
||||
const discoveryLayer = Layer.succeed(
|
||||
SkillDiscovery.Service,
|
||||
SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls++
|
||||
if (failedUrls.has(url)) return Effect.die(`failed to pull ${url}`)
|
||||
return Effect.succeed(urls.get(url) ?? [])
|
||||
},
|
||||
}),
|
||||
)
|
||||
const watcherLayer = Watcher.testLayer
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
|
||||
discoveryLayer,
|
||||
watcherLayer,
|
||||
),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.effect("registers configured skill directories and URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = AbsolutePath.make("/repo/packages/app")
|
||||
const sources: Skill.Source[] = []
|
||||
const transform = Effect.fnUntraced(function* (update: (draft: Skill.Draft) => void | Effect.Effect<void>) {
|
||||
const result = update({
|
||||
source: (source) => {
|
||||
sources.push(source)
|
||||
},
|
||||
list: () => sources,
|
||||
})
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
const dispose = Effect.sync(() => {
|
||||
sources.length = 0
|
||||
})
|
||||
yield* Effect.addFinalizer(() => dispose)
|
||||
return { dispose }
|
||||
})
|
||||
function write(directory: string, name: string, description: string) {
|
||||
return fs.writeFile(
|
||||
path.join(directory, name, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
# ${name}`,
|
||||
)
|
||||
}
|
||||
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
|
||||
const service = yield* Skill.Service
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: {
|
||||
list: () => Effect.die("unused skill.list"),
|
||||
transform: service.transform,
|
||||
reload: service.reload,
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(Config.testLayer(entries)),
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
)
|
||||
return service
|
||||
})
|
||||
|
||||
const start = (skills: string[], directory: string) =>
|
||||
startEntries(
|
||||
[
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ skills }),
|
||||
}),
|
||||
],
|
||||
directory,
|
||||
)
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
const deferred = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* watcher.emit(update)
|
||||
yield* Deferred.await(deferred).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(fiber)
|
||||
})
|
||||
}
|
||||
|
||||
describe("SkillFile.parse", () => {
|
||||
it.effect("parses root and nested skill ids and metadata flags", () =>
|
||||
Effect.sync(() => {
|
||||
const directory = "/repo/skills"
|
||||
expect(
|
||||
SkillFile.parse(
|
||||
directory,
|
||||
"/repo/skills/manual/SKILL.md",
|
||||
`---
|
||||
name: Manual
|
||||
description: Manual only
|
||||
metadata:
|
||||
opencode/slash: "true"
|
||||
opencode/autoinvoke: false
|
||||
---
|
||||
# manual`,
|
||||
),
|
||||
)
|
||||
|
||||
expect(sources).toEqual([
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.claude", "skills")),
|
||||
}),
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.agents", "skills")),
|
||||
}),
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
|
||||
}),
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
|
||||
}),
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/home/test", "shared-skills")),
|
||||
}),
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
|
||||
Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
|
||||
])
|
||||
).toEqual({
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("Manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
|
||||
content: "# manual",
|
||||
},
|
||||
})
|
||||
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("foo") },
|
||||
})
|
||||
expect(
|
||||
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
|
||||
).toEqual({ _tag: "Skipped", reason: "markdown" })
|
||||
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
|
||||
_tag: "Skipped",
|
||||
reason: "frontmatter",
|
||||
issue: expect.anything(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.live("maps config entry types to skill directories", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const claude = path.join(tmp.path, "claude")
|
||||
const agents = path.join(tmp.path, "agents")
|
||||
const opencode = path.join(tmp.path, "opencode")
|
||||
const home = path.join(tmp.path, "home")
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const expected = [
|
||||
path.join(claude, "skills"),
|
||||
path.join(agents, "skills"),
|
||||
path.join(opencode, "skill"),
|
||||
path.join(opencode, "skills"),
|
||||
path.join(home, "shared"),
|
||||
path.join(directory, "relative"),
|
||||
]
|
||||
yield* Effect.promise(() => Promise.all(expected.map((item) => fs.mkdir(item, { recursive: true }))))
|
||||
|
||||
yield* startEntries(
|
||||
[
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(claude) }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make(agents) }),
|
||||
new ConfigDirectory({ type: "directory", path: AbsolutePath.make(opencode) }),
|
||||
new Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
|
||||
],
|
||||
directory,
|
||||
home,
|
||||
)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory" })))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads directory and URL sources with later-source precedence", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "review"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "review"), { recursive: true })
|
||||
await write(first, "review", "First")
|
||||
await write(second, "review", "Second")
|
||||
})
|
||||
pulls = 0
|
||||
urls.set("https://example.test/skills/", [AbsolutePath.make(second)])
|
||||
|
||||
const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
|
||||
expect(pulls).toBe(1)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps directory skills when a URL source fails", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Available")
|
||||
})
|
||||
const url = "https://unreachable.example.test/skills/"
|
||||
failedUrls.add(url)
|
||||
|
||||
const skill = yield* start([tmp.path, url], tmp.path)
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Available")
|
||||
failedUrls.delete(url)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rescans directory sources when watched files change", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Initial")
|
||||
})
|
||||
const skill = yield* start([tmp.path], tmp.path)
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial")
|
||||
|
||||
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated"))
|
||||
yield* emitAndWait({ type: "update", path: deploy })
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Review")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "review", "SKILL.md") })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([
|
||||
Skill.ID.make("deploy"),
|
||||
Skill.ID.make("review"),
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
|
||||
yield* emitAndWait({ type: "delete", path: path.join(tmp.path, "review", "SKILL.md") })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches canonical directories behind symlinked skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const target = path.join(tmp.path, "target", "bro")
|
||||
const file = path.join(target, "SKILL.md")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(source, { recursive: true })
|
||||
await fs.mkdir(target, { recursive: true })
|
||||
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
|
||||
await fs.symlink(target, path.join(source, "bro"), process.platform === "win32" ? "junction" : undefined)
|
||||
})
|
||||
|
||||
const skill = yield* start([source], tmp.path)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
|
||||
expect(yield* watcher.subscriptions()).toContainEqual({ path: target, type: "directory" })
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
|
||||
yield* emitAndWait({ type: "update", path: file })
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads symlinked sources when their target changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "bro"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "bro"), { recursive: true })
|
||||
await write(first, "bro", "First")
|
||||
await write(second, "bro", "Second")
|
||||
await fs.symlink(first, source, process.platform === "win32" ? "junction" : undefined)
|
||||
})
|
||||
|
||||
const skill = yield* start([source], tmp.path)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(source)
|
||||
await fs.symlink(second, source, process.platform === "win32" ? "junction" : undefined)
|
||||
})
|
||||
yield* emitAndWait({ type: "update", path: source })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("follows missing source directories as their parents appear", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "generated", "skills")
|
||||
const skill = yield* start([source], tmp.path)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(source, "deploy"), { recursive: true })
|
||||
await write(source, "deploy", "Deploy")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: source })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,11 +11,19 @@ import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
|
||||
const run = <A, E>(
|
||||
effect: Effect.Effect<A, E, SqlClient | Global.Service>,
|
||||
global = Global.make({ data: path.join(process.cwd(), ".test-data") }),
|
||||
) =>
|
||||
Effect.runPromise(
|
||||
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
|
||||
effect.pipe(
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
|
||||
Effect.scoped,
|
||||
),
|
||||
)
|
||||
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
@@ -31,7 +39,7 @@ describe("DatabaseMigration", () => {
|
||||
Effect.scoped(Layer.build(layer)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -127,7 +135,8 @@ describe("DatabaseMigration", () => {
|
||||
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
|
||||
`)
|
||||
|
||||
yield* db.transaction((tx) => importLegacyCredentials(tx, source))
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
|
||||
[
|
||||
@@ -159,11 +168,28 @@ describe("DatabaseMigration", () => {
|
||||
value: JSON.stringify(["https://example.com"]),
|
||||
})
|
||||
}),
|
||||
Global.make({ data: tmp.path }),
|
||||
)
|
||||
|
||||
expect(await Bun.file(source).text()).toBe(content)
|
||||
})
|
||||
|
||||
test("skips legacy credential import when the source file is absent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
|
||||
}),
|
||||
Global.make({ data: tmp.path }),
|
||||
)
|
||||
})
|
||||
|
||||
test("rolls back a failed migration without recording it", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "./tmpdir"
|
||||
|
||||
export const tempGlobalLayer = Layer.unwrap(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.map((tmp) => {
|
||||
const data = path.join(tmp.path, "data")
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
return Global.layerWith({
|
||||
home: path.join(tmp.path, "home"),
|
||||
data,
|
||||
cache,
|
||||
config: path.join(tmp.path, "config"),
|
||||
state: path.join(tmp.path, "state"),
|
||||
tmp: path.join(tmp.path, "tmp"),
|
||||
bin: path.join(cache, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
repos: path.join(data, "repos"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("global paths", () => {
|
||||
test("tmp path is the canonical system temp directory", async () => {
|
||||
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
|
||||
expect(Global.make().tmp).toBe(Global.Path.tmp)
|
||||
})
|
||||
|
||||
test("tmp path is created on module load", async () => {
|
||||
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,107 +1,215 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { readInitial, readUpdate, state } from "./lib/instructions"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
const instructionLayer = (input: {
|
||||
config: string
|
||||
config?: string
|
||||
home?: string
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
project?: boolean
|
||||
}) =>
|
||||
AppNodeBuilder.build(InstructionDiscovery.node, [
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
|
||||
[Global.node, Global.layerWith({ config: input.config })],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
])
|
||||
}) => {
|
||||
const watcher = Watcher.testLayer
|
||||
return Layer.mergeAll(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
|
||||
[
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
|
||||
[
|
||||
Global.node,
|
||||
input.config || input.home
|
||||
? Global.layerWith({
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
...(input.home ? { home: input.home } : {}),
|
||||
})
|
||||
: tempGlobalLayer,
|
||||
],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
[Watcher.node, watcher],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
],
|
||||
),
|
||||
watcher,
|
||||
)
|
||||
}
|
||||
|
||||
const start = Effect.fnUntraced(function* () {
|
||||
yield* ConfigInstructionPlugin.Plugin.effect(host())
|
||||
return yield* InstructionDiscovery.Service
|
||||
})
|
||||
|
||||
const file = (path: string, content: string) =>
|
||||
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(updated, undefined).pipe(Effect.asVoid)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* watcher.emit(update)
|
||||
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(fiber)
|
||||
})
|
||||
}
|
||||
|
||||
describe("InstructionDiscovery", () => {
|
||||
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
|
||||
it.effect("stores ordered values with last-write-wins precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.add(file("/repo/AGENTS.md", "first"))
|
||||
draft.add(file("/repo/packages/AGENTS.md", "package"))
|
||||
draft.add(file("/repo/AGENTS.md", "last"))
|
||||
draft.update("/repo/packages/AGENTS.md", (current) => {
|
||||
current.content = "updated"
|
||||
current.path = AbsolutePath.make("/ignored")
|
||||
})
|
||||
draft.remove("/missing")
|
||||
})
|
||||
|
||||
expect(yield* discovery.list()).toEqual([
|
||||
file("/repo/AGENTS.md", "last"),
|
||||
file("/repo/packages/AGENTS.md", "updated"),
|
||||
])
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
|
||||
it.effect("preserves admitted values while the source is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* discovery.transform((draft) => draft.unavailable())
|
||||
expect(
|
||||
(yield* readUpdate(
|
||||
yield* discovery.load(),
|
||||
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
|
||||
)).changed,
|
||||
).toBe(false)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
it.live("loads global and upward project files and rescans them on change", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const outside = path.join(tmp.path, "AGENTS.md")
|
||||
const globalFile = path.join(global, "AGENTS.md")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
const packageFile = path.join(directory, "AGENTS.md")
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(tmp.path, "home")
|
||||
const shared = path.join(home, "code")
|
||||
const project = path.join(shared, "repo")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const outside = path.join(tmp.path, "AGENTS.md")
|
||||
const globalFile = path.join(global, "AGENTS.md")
|
||||
const sharedFile = path.join(shared, "AGENTS.md")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
const packageFile = path.join(directory, "AGENTS.md")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.writeFile(outside, "outside")
|
||||
await fs.writeFile(globalFile, "global")
|
||||
await fs.writeFile(sharedFile, "shared")
|
||||
await fs.writeFile(projectFile, "project")
|
||||
await fs.writeFile(packageFile, "package")
|
||||
})
|
||||
|
||||
const load = InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const initialized = yield* readInitial(yield* load)
|
||||
const discovery = yield* start()
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: globalFile, type: "file" },
|
||||
{ path: packageFile, type: "file" },
|
||||
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
|
||||
{ path: projectFile, type: "file" },
|
||||
{ path: sharedFile, type: "file" },
|
||||
{ path: path.join(home, "AGENTS.md"), type: "file" },
|
||||
])
|
||||
expect(yield* watcher.subscriptions()).not.toContainEqual({
|
||||
path: path.join(tmp.path, "AGENTS.md"),
|
||||
type: "file",
|
||||
})
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${packageFile}\npackage`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
`Instructions from: ${sharedFile}\nshared`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
expect(initialized.text).not.toContain("outside")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||
expect((yield* readUpdate(yield* load, initialized)).text).toContain(
|
||||
yield* emitAndWait({ type: "update", path: packageFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
|
||||
`Instructions from: ${packageFile}\nchanged`,
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(packageFile))
|
||||
const partial = yield* readUpdate(yield* load, initialized)
|
||||
expect(partial.text).toBe(
|
||||
yield* emitAndWait({ type: "delete", path: packageFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
[
|
||||
"These instructions replace all previously loaded ambient instructions.",
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
`Instructions from: ${sharedFile}\nshared`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||
expect((yield* readUpdate(yield* load, initialized)).text).toBe(
|
||||
yield* Effect.promise(() => fs.rm(globalFile))
|
||||
yield* emitAndWait({ type: "delete", path: globalFile })
|
||||
yield* Effect.promise(() => fs.rm(projectFile))
|
||||
yield* emitAndWait({ type: "delete", path: projectFile })
|
||||
yield* Effect.promise(() => fs.rm(sharedFile))
|
||||
yield* emitAndWait({ type: "delete", path: sharedFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
"Previously loaded instructions no longer apply.",
|
||||
)
|
||||
}),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
home,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -114,115 +222,194 @@ describe("InstructionDiscovery", () => {
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "AGENTS.md")
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
|
||||
}),
|
||||
const discovery = yield* start()
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${file}\n`)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves admitted instructions while observation is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const failingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: failingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
it.live("discovers a newly created instruction file above the project root", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const home = path.join(tmp.path, "home")
|
||||
const shared = path.join(home, "code")
|
||||
const project = path.join(shared, "repo")
|
||||
const intermediate = path.join(shared, "AGENTS.md")
|
||||
const directory = path.join(project, "core")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(projectFile, "project"))
|
||||
const discovery = yield* start()
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${projectFile}\nproject`)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate"))
|
||||
yield* emitAndWait({ type: "create", path: intermediate })
|
||||
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
||||
[`Instructions from: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join(
|
||||
"\n\n",
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] })))
|
||||
.changed,
|
||||
).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves admitted instructions when a discovered file disappears before read", () =>
|
||||
Effect.gen(function* () {
|
||||
const file = AbsolutePath.make("/repo/AGENTS.md")
|
||||
const racingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: () => Effect.succeed([file]),
|
||||
readFileStringSafe: () => Effect.succeed(undefined),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
home,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: racingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed,
|
||||
).toBe(false)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("canonicalizes upward discovery boundaries", () =>
|
||||
it.live("stops instruction candidates at the project root outside home", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(tmp.path, "home")
|
||||
const project = path.join(tmp.path, "scratch", "repo")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
yield* start()
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(global, "AGENTS.md"), type: "file" },
|
||||
{ path: path.join(directory, "AGENTS.md"), type: "file" },
|
||||
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
|
||||
{ path: path.join(project, "AGENTS.md"), type: "file" },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
home,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("isolates source failure without failing activation", () => {
|
||||
const failingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
return Effect.gen(function* () {
|
||||
const discovery = yield* start()
|
||||
expect(
|
||||
(yield* readUpdate(
|
||||
yield* discovery.load(),
|
||||
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
|
||||
)).changed,
|
||||
).toBe(false)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: failingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("marks a discovered file that disappears before read as unavailable", () => {
|
||||
const discovered = AbsolutePath.make("/repo/AGENTS.md")
|
||||
const racingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: () => Effect.succeed([discovered]),
|
||||
readFileStringSafe: () => Effect.succeed(undefined),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
return Effect.gen(function* () {
|
||||
const discovery = yield* start()
|
||||
expect(
|
||||
(yield* readUpdate(
|
||||
yield* discovery.load(),
|
||||
state({ "core/instructions": [{ path: discovered, content: "old" }] }),
|
||||
)).changed,
|
||||
).toBe(false)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: racingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("canonicalizes boundaries and honors project opt-out", () =>
|
||||
Effect.gen(function* () {
|
||||
let observed: { targets: string[]; start: string; stop?: string } | undefined
|
||||
const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
|
||||
const observingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: (options) =>
|
||||
Effect.sync(() => {
|
||||
observed = options
|
||||
return []
|
||||
}),
|
||||
up: (options) => Effect.sync(() => (observed.values.push(options), [])),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
yield* start().pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: observingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -233,31 +420,11 @@ describe("InstructionDiscovery", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(observed).toEqual({
|
||||
targets: ["AGENTS.md"],
|
||||
start: FSUtil.resolve("/repo"),
|
||||
stop: FSUtil.resolve("/repo"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("honors the project instruction opt-out", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
yield* start().pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: observingFS,
|
||||
project: false,
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
@@ -265,25 +432,10 @@ describe("InstructionDiscovery", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(scanned).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not discover project instructions outside the canonical project root", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
yield* start().pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
filesystemLayer: observingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
@@ -297,7 +449,8 @@ describe("InstructionDiscovery", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(scanned).toBe(false)
|
||||
const repo = path.resolve("/repo")
|
||||
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import os from "os"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -16,6 +17,7 @@ const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
||||
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
||||
const sessionID = SessionSchema.ID.make("ses_builtin_test")
|
||||
const temporary = os.tmpdir()
|
||||
const localDate = (time: number) => new Date(time).toDateString()
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -29,7 +31,7 @@ const locationLayer = Layer.succeed(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(InstructionBuiltIns.node, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
|
||||
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -49,7 +51,7 @@ describe("InstructionBuiltIns", () => {
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
|
||||
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user