diff --git a/.github/workflows/branch-sync.yml b/.github/workflows/branch-sync.yml deleted file mode 100644 index 0b0e486c91..0000000000 --- a/.github/workflows/branch-sync.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: branch-sync - -# Fails when `main` carries a commit `develop` does not, which is the back-merge step being skipped. -# -# `docs/BRANCHING.md` has told hotfixes to merge `main` back into `develop` since it was written. -# The rule was not the problem: #13 fixed `bun install --frozen-lockfile` on `main` and the back-merge -# never happened, so the DEFAULT branch could not install with the bun version `packageManager` pins -# for a month. Nothing anywhere said so. Documentation that nothing checks is a preference. -# -# On `push` to `main` rather than on a schedule, because that is the moment the divergence is -# created and the person who created it is still looking. A promotion pull request from `develop` -# leaves the two identical and says nothing; a hotfix merged straight into `main` reports until it -# is brought back. -# -# It FAILS rather than posting a notice. A notice on a branch nobody watches is how this went -# unnoticed the first time, and a red mark on `main` is the point: the repository is in a state -# where a branch cut from the default branch inherits a known-broken tree. - -on: - push: - branches: [main] - # Lets the check be re-run once the back-merge has landed, without pushing a commit. - workflow_dispatch: - -permissions: - contents: read - -jobs: - back-merged: - name: main is back-merged into develop - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # Full history: `rev-list` between two branches needs the commits, and a shallow clone - # answers with whatever it happens to have, which is worse than no answer. - fetch-depth: 0 - - - name: Compare main against develop - run: | - set -euo pipefail - if ! git rev-parse -q --verify origin/develop >/dev/null; then - echo "::notice::no develop branch; nothing to back-merge into" - exit 0 - fi - missing="$(git rev-list --count origin/develop..origin/main)" - if [ "$missing" -eq 0 ]; then - echo "main is contained in develop" - exit 0 - fi - { - echo "### \`main\` has $missing commit(s) \`develop\` does not" - echo - echo "Merge \`main\` back into \`develop\`, per docs/BRANCHING.md. Until that lands, a" - echo "branch cut from the default branch is missing whatever these changed." - echo - git log --format='- `%h` %s' origin/develop..origin/main - } >> "$GITHUB_STEP_SUMMARY" - git log --oneline origin/develop..origin/main - echo "::error::main is $missing commit(s) ahead of develop; back-merge it" - exit 1 diff --git a/.github/workflows/cdn-version-marker.yml b/.github/workflows/cdn-version-marker.yml new file mode 100644 index 0000000000..7d6bc64b36 --- /dev/null +++ b/.github/workflows/cdn-version-marker.yml @@ -0,0 +1,89 @@ +name: CDN version marker + +# Keeps `https://cdn.redrob.ai/code/latest/version` equal to the newest GitHub release. +# +# Builds live on GitHub Releases, not on the CDN, since 0.1.0. The marker still matters: +# every `redrob` from the 0.0.x line asks it what the newest version is before running the +# Console installer, and while it said `0.0.12` those installs believed they were current +# and `redrob upgrade` did nothing. Writing the marker on each release is what lets them +# move; the installer they then run downloads from GitHub. +# +# The bucket name and the keys are the organisation's CDN variables, the same ones +# redrob-shape and redrob-browser-agent publish with. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: Version to write (no leading v). Empty = the newest non-prerelease GitHub release. + required: false + default: "" + +permissions: + contents: read + +concurrency: + group: cdn-version-marker + cancel-in-progress: false + +jobs: + write: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + REDROB_CDN_BUCKET: ${{ vars.REDROB_CDN_BUCKET }} + AWS_ACCESS_KEY_ID: ${{ secrets.REDROB_CDN_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.REDROB_CDN_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: us-east-1 + REQUESTED: ${{ inputs.version }} + steps: + - name: resolve the version + id: version + run: | + set -euo pipefail + if [ -n "$REQUESTED" ]; then + version="${REQUESTED#v}" + elif [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" != "true" ]; then + version="${{ github.event.release.tag_name }}" + version="${version#v}" + else + version="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-pre-releases --exclude-drafts \ + --json tagName -q '[.[] | .tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))] | .[0]')" + version="${version#v}" + fi + case "$version" in + [0-9]*.[0-9]*.[0-9]*) ;; + *) echo "::error::not a release version: '$version'"; exit 1 ;; + esac + # The marker never goes backwards: a re-published old release must not demote it. + current="$(curl --fail --silent --location https://cdn.redrob.ai/code/latest/version || true)" + if [ -n "$current" ] && [ "$(printf '%s\n%s\n' "$current" "$version" | sort -V | tail -n1)" != "$version" ]; then + echo "::notice::marker is $current, newer than $version; leaving it." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: write code/latest/version + if: ${{ steps.version.outputs.skip != 'true' }} + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + if [ -z "$REDROB_CDN_BUCKET" ]; then + echo "::error::REDROB_CDN_BUCKET is not set"; exit 1 + fi + printf '%s\n' "$VERSION" > version + aws s3 cp version "s3://${REDROB_CDN_BUCKET}/code/latest/version" \ + --content-type "text/plain" --cache-control "public, max-age=300" --no-progress + # The CDN key can write the bucket but not read it (HeadObject is 403), so the upload's + # own exit status is the proof. The CDN in front has no cache-control on the old object, + # so it may serve the previous marker for up to its default TTL (a day); that is reported, + # not failed. + echo "OK s3://${REDROB_CDN_BUCKET}/code/latest/version = $VERSION" + cdn="$(curl --fail --silent --location https://cdn.redrob.ai/code/latest/version || true)" + if [ "$cdn" != "$VERSION" ]; then + echo "::notice::cdn.redrob.ai still serves '$cdn'; it refreshes when the cached copy expires." + fi diff --git a/.github/workflows/engine-client.yml b/.github/workflows/engine-client.yml new file mode 100644 index 0000000000..7613fd1c99 --- /dev/null +++ b/.github/workflows/engine-client.yml @@ -0,0 +1,40 @@ +# The Rust client for the shared daemon (crates/engine-client). Runs only when the crate or +# this file changes: the rest of the repository is TypeScript and does not need a Rust toolchain. +name: engine-client + +on: + pull_request: + paths: + - "crates/engine-client/**" + - ".github/workflows/engine-client.yml" + push: + branches: [develop, main] + paths: + - "crates/engine-client/**" + - ".github/workflows/engine-client.yml" + +permissions: + contents: read + +jobs: + check: + name: engine-client (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + defaults: + run: + working-directory: crates/engine-client + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates/engine-client + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo test --locked diff --git a/.github/workflows/gitflow.yml b/.github/workflows/gitflow.yml index 337c1c76c3..8c75c9c488 100644 --- a/.github/workflows/gitflow.yml +++ b/.github/workflows/gitflow.yml @@ -1,127 +1,21 @@ -name: gitflow +# Gitflow rules shared by every redrob-labs repository: branch names are / from the +# org-wide type list, and main is contained in develop (a back-merge pull request is opened when +# it is not). The rules live in redrob-labs/.github/.github/workflows/gitflow.yml; this file only +# calls them. Do not add a type list here -- there is one, and it is there. -# The back-merge half of the shared gitflow workflow is NOT here: `branch-sync.yml` owns that rule. -# -# One rule this repository already had in prose, now checked. -# -# A rule nothing checks is a preference. It was written down and broken anyway: branches appeared -# under a `kiro/` prefix that no document defines. -# -# A branch name can only be judged when a pull request exists, which is why this runs on -# `pull_request` and not on push. +name: gitflow on: pull_request: - # No branch filter. A filter that omits the default branch silently stops running, which is exactly - # what happened in a sibling repository when the default moved to `develop` and three workflows - # kept filtering on `[main, master]`. types: [opened, reopened, edited, synchronize] + push: + branches: [main] workflow_dispatch: permissions: - contents: read + contents: write + pull-requests: write jobs: - tag-hygiene: - name: no workflow pushes tags in bulk - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 1 - - - name: Refuse bulk tag pushes and unnamespaced upstream fetches - run: | - set -euo pipefail - - # This fork inherited over a thousand upstream tags, which buried its own releases and - # made a `v*` glob deletion unusable. They were deleted. Two command shapes would undo - # that in one run, so neither may appear in a workflow: - # - # a tag push with no tag named republishes whatever is in refs/tags - # a tag fetch from `upstream` refills refs/tags from upstream - # - # The release workflow pushes exactly one tag, named in full as `refs/tags/$TAG`, and - # that is the only allowed shape. Fetching OUR OWN tags from `origin` is fine and is not - # matched: the release-tagging actions depend on it. See docs/VERSIONING.md. - # - # Comment lines are stripped before matching. Without that this gate fails on the very - # comment above, which names both banned shapes in order to explain them. - bad=0 - while IFS= read -r file; do - body="$(sed 's/[[:space:]]*#.*$//' "$file")" - - if printf '%s\n' "$body" | grep -nE 'git[[:space:]]+push[^|&;]*--(tags|follow-tags)'; then - echo "::error file=$file::pushes tags in bulk; push one tag by its full refs/tags/" - bad=1 - fi - - # Only upstream's tags are the problem, and only when no refspec redirects them out - # of refs/tags. - if printf '%s\n' "$body" \ - | grep -E 'git[[:space:]]+fetch[^|&;]*--tags[^|&;]*upstream|git[[:space:]]+fetch[^|&;]*upstream[^|&;]*--tags' \ - | grep -qv 'refs/upstream-tags/'; then - echo "::error file=$file::fetches upstream tags into refs/tags; use '+refs/tags/*:refs/upstream-tags/*'" - bad=1 - fi - done < <(find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \)) - - if [ "$bad" -ne 0 ]; then - exit 1 - fi - echo "no bulk tag pushes and no unnamespaced upstream tag fetches" - - branch-name: - name: branch name follows the convention - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - name: Check the head branch name - env: - HEAD_REF: ${{ github.head_ref }} - BASE_REF: ${{ github.base_ref }} - run: | - set -euo pipefail - - # ALLOWED_TYPES is this repository's own list. Keep it in step with the branch table in the - # contributing or branching document; the document is the explanation, this is the gate. - ALLOWED_TYPES='feature sync hotfix release' - - # `develop` and `main` reach here through a promotion or a back-merge pull request, which are - # branches in their own right and are not named after a type. - case "$HEAD_REF" in - develop|main) echo "$HEAD_REF is a long-lived branch, not a working branch"; exit 0 ;; - esac - - type="${HEAD_REF%%/*}" - slug="${HEAD_REF#*/}" - - if [ "$type" = "$HEAD_REF" ]; then - echo "::error::branch '$HEAD_REF' has no type prefix. Use /, type one of: $ALLOWED_TYPES" - exit 1 - fi - - ok=false - for t in $ALLOWED_TYPES; do - [ "$type" = "$t" ] && ok=true && break - done - - if [ "$ok" != true ]; then - { - echo "### Branch name is not one of this repository's types" - echo - echo "\`$HEAD_REF\` uses the prefix \`$type\`. Allowed: $ALLOWED_TYPES" - echo - echo "A branch name says what the change is, not who or what made it. An agent or tool name" - echo "is not a type: rename the branch and reopen the pull request." - } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - echo "::error::branch prefix '$type' is not allowed. Use one of: $ALLOWED_TYPES" - exit 1 - fi - - if [ -z "$slug" ] || [ "$slug" = "$type" ]; then - echo "::error::branch '$HEAD_REF' has an empty slug. Use /." - exit 1 - fi - - echo "$HEAD_REF is a valid $type branch targeting $BASE_REF" + gitflow: + uses: redrob-labs/.github/.github/workflows/gitflow.yml@main diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml new file mode 100644 index 0000000000..29e543aec1 --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,17 @@ +# Pull request policy: at most 3 open pull requests per author, and a maintainer review may be +# requested only after the author has reviewed their own pull request. Rules and defaults live in +# redrob-labs/.github/.github/workflows/pr-policy.yml; pass `with:` to change them for this +# repository. + +name: PR policy + +on: + pull_request_target: + types: [opened, reopened, review_requested] + +permissions: + pull-requests: write + +jobs: + policy: + uses: redrob-labs/.github/.github/workflows/pr-policy.yml@main diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml index 09ac5f2975..0e5a3f257b 100644 --- a/.github/workflows/publish-sdk.yml +++ b/.github/workflows/publish-sdk.yml @@ -44,8 +44,13 @@ concurrency: group: publish-sdk cancel-in-progress: false +# `id-token: write` is what npm trusted publishing needs: npm exchanges the GitHub OIDC token for a +# short-lived publish credential, so no NPM_TOKEN secret is stored anywhere. The trust is configured +# on npmjs.com (package settings > Trusted publisher: this repository, this workflow file). When +# NPM_TOKEN IS set it still wins, so the old path keeps working during the switch. permissions: contents: read + id-token: write jobs: publish: @@ -62,6 +67,12 @@ jobs: - uses: ./.github/actions/setup-bun + # Trusted publishing needs npm >= 11.5.1 (Node 24 ships one); the runner's default is older. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + - run: npm install -g npm@latest && npm --version + - id: resolve env: REQUESTED: ${{ inputs.version }} @@ -100,8 +111,8 @@ jobs: run: | set -euo pipefail if [ -z "${NPM_TOKEN:-}" ]; then - echo "::error::NPM_TOKEN is not set for this repository or its organization" - exit 1 + echo "no NPM_TOKEN: publishing through npm trusted publishing (OIDC)" + exit 0 fi echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc if ! who="$(npm whoami 2>&1)"; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b3cf71006..1af8c8b817 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,10 @@ jobs: version: ${{ steps.compute.outputs.version }} tag: ${{ steps.compute.outputs.tag }} upstream: ${{ steps.compute.outputs.upstream }} + # The one commit this release is. Every later job checks THIS out, not `develop`: + # a merge that lands while the build runs must not make the tag point at code the + # binaries were not built from (it did, once, with a merge two minutes after dispatch). + sha: ${{ steps.compute.outputs.sha }} steps: # Tags are the version state, so this job needs them. `fetch-tags` alone is not # enough with a shallow checkout: the tag objects arrive without the commits they @@ -109,6 +113,7 @@ jobs: upstream="$(tr -d '[:space:]' < UPSTREAM_VERSION)" echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "upstream=$upstream" >> "$GITHUB_OUTPUT" echo "::notice::releasing v$version (built on opencode v$upstream)" @@ -118,7 +123,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - ref: develop + ref: ${{ needs.version.outputs.sha }} fetch-depth: 1 - uses: ./.github/actions/setup-bun @@ -235,7 +240,7 @@ jobs: - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: cli-macos-signed + name: release-macos path: dist/redrob-darwin-*.zip # `if: always()` so a failed notarization still takes the private key off the runner. @@ -249,37 +254,27 @@ jobs: sign-windows: needs: [version, build] runs-on: windows-latest - outputs: - # Carried to `publish` so the release notes state what actually happened. Notes that - # claim a signature the run did not produce are worse than no claim: a reader has no - # way to tell, and the whole point of saying "signed" is that it can be relied on. - signed: ${{ steps.result.outputs.signed }} - # Separate from `signed`: the binaries can be signed while the installer is not, and the - # notes must not round one up into the other. - installer: ${{ steps.installer-result.outputs.installer }} - # WHICH method signed, not just whether something did. The notes used to name Azure - # Trusted Signing unconditionally while this repository signs with the organization - # Authenticode certificate, so every published release claimed a method it had not - # used. `signed` cannot carry that -- it is a boolean about success -- and a reader has - # no way to check, which is exactly the failure the comment above warns about. - method: ${{ steps.config.outputs.method }} + # `release` is the environment the organization's federated credential names + # (repo:redrob-labs@/@:environment:release); any other name is refused. + environment: release + permissions: + contents: read + # The OIDC token `azure/login` exchanges for the Artifact Signing credential. + id-token: write env: - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - AZURE_ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} - AZURE_ARTIFACT_SIGNING_ACCOUNT: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT }} - AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE }} - WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} - WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + REDROB_WINDOWS_BINARIES: >- + dist/redrob-windows-arm64/bin/redrob.exe + dist/redrob-windows-x64/bin/redrob.exe + dist/redrob-windows-x64-baseline/bin/redrob.exe + REDROB_WINDOWS_INSTALLER: dist/redrob-code-x64-setup.exe steps: - # This job used to download the build and nothing else, because signing needs only the - # artifact. The installer definition lives in the repository, so it needs a checkout -- and - # without one `ISCC` was handed a path to a file that was never on the runner. Sparse, because - # cloning this whole repository to read one .iss file is a minute of clone for nothing. + # Only the installer definition is needed from the repository; the signing recipe is the + # organization's (redrob-labs/.github/actions/sign-windows), not a copy kept here. Sparse, + # because cloning this whole repository to read one directory is a minute of clone for nothing. - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - sparse-checkout: installer + sparse-checkout: | + installer sparse-checkout-cone-mode: true - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -287,189 +282,41 @@ jobs: name: cli-unsigned path: dist - # There are two ways to sign here and the choice is made once, up front, so no later - # step re-derives it and a half-configured method is reported as such rather than - # failing inside an Azure login with an unrelated message. - # - # Azure Trusted Signing is preferred when configured: the key never reaches the - # runner. The organization Authenticode PFX is the fallback, and it does put a private - # key on the runner, which is why it is second and why the file is deleted immediately - # after use. - - name: Detect the signing configuration - id: config - shell: bash - run: | - set -euo pipefail - - azure_present=0 - azure_missing=() - for name in \ - AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \ - AZURE_ARTIFACT_SIGNING_ENDPOINT AZURE_ARTIFACT_SIGNING_ACCOUNT \ - AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE; do - if [ -n "${!name:-}" ]; then azure_present=$((azure_present + 1)); else azure_missing+=("$name"); fi - done - - pfx_present=0 - pfx_missing=() - for name in WIN_CSC_LINK WIN_CSC_KEY_PASSWORD; do - if [ -n "${!name:-}" ]; then pfx_present=$((pfx_present + 1)); else pfx_missing+=("$name"); fi - done - - # A PARTIAL configuration is a mistake, not a choice, so it is fatal either way -- - # silently falling back from a half-configured Azure to a PFX would hide the - # mistake and sign with something other than what was intended. - if [ "$azure_present" -gt 0 ] && [ "$azure_present" -lt 6 ]; then - echo "::error::partial Azure signing configuration; missing: ${azure_missing[*]}" - exit 1 - fi - if [ "$pfx_present" -eq 1 ]; then - echo "::error::partial Authenticode configuration; missing: ${pfx_missing[*]}" - exit 1 - fi - - if [ "$azure_present" -eq 6 ]; then - echo "method=azure" >> "$GITHUB_OUTPUT" - echo "::notice::signing Windows binaries with Azure Trusted Signing" - elif [ "$pfx_present" -eq 2 ]; then - echo "method=pfx" >> "$GITHUB_OUTPUT" - echo "::notice::signing Windows binaries with the organization Authenticode certificate" - else - echo "method=none" >> "$GITHUB_OUTPUT" - echo "::warning::no Windows signing method is configured; the binaries will be unsigned" - fi - - - name: Azure login - if: steps.config.outputs.method == 'azure' - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 + # Azure Artifact Signing, authenticated with OpenID Connect: the job's GitHub token is + # exchanged for an Azure token against the organization's app registration, whose federated + # credential accepts `repo:redrob-labs@/*:environment:release`. No certificate file + # and no client secret exist anywhere. The login comes AFTER the download and right BEFORE + # signing: GitHub's OIDC token lives minutes, and a login done earlier left signtool without + # a usable credential. + - name: Log in to Azure (OIDC) + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3 with: - client-id: ${{ env.AZURE_CLIENT_ID }} - tenant-id: ${{ env.AZURE_TENANT_ID }} - subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - - name: Sign with Azure Trusted Signing - id: sign-azure - continue-on-error: true - if: steps.config.outputs.method == 'azure' - uses: azure/artifact-signing-action@b443cf8ea4124818d2ea9f043cba29fc3ec47b16 # v1.2.0 + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + # The shared action signs, then reads every file back (`signtool verify /pa`, Status Valid, + # signer CN `McKinley Rice, Inc.`) and prints ` signed by CN=...`. With the three + # ARTIFACT_SIGNING_* secrets all unset it warns and leaves the files unsigned; half-set is an + # error. A failed signature fails the job: a release never ships an unsigned Windows binary + # behind a green check. + - name: Sign the binaries + uses: redrob-labs/.github/actions/sign-windows@main with: - endpoint: ${{ env.AZURE_ARTIFACT_SIGNING_ENDPOINT }} - signing-account-name: ${{ env.AZURE_ARTIFACT_SIGNING_ACCOUNT }} - certificate-profile-name: ${{ env.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE }} - files: | - ${{ github.workspace }}\dist\redrob-windows-arm64\bin\redrob.exe - ${{ github.workspace }}\dist\redrob-windows-x64\bin\redrob.exe - ${{ github.workspace }}\dist\redrob-windows-x64-baseline\bin\redrob.exe - exclude-environment-credential: true - exclude-workload-identity-credential: true - exclude-managed-identity-credential: true - exclude-shared-token-cache-credential: true - exclude-visual-studio-credential: true - exclude-visual-studio-code-credential: true - exclude-azure-cli-credential: false - exclude-azure-powershell-credential: true - exclude-azure-developer-cli-credential: true - exclude-interactive-browser-credential: true - - # The fallback. Unlike Trusted Signing this puts a private key on the runner, so the - # PFX is written, imported, and deleted within the one step -- `finally` so a signing - # failure does not leave it behind. - - name: Sign with the organization Authenticode certificate - id: sign-pfx - continue-on-error: true - if: steps.config.outputs.method == 'pfx' - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $pfx = Join-Path $env:RUNNER_TEMP "redrob-code-signing.pfx" - try { - [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WIN_CSC_LINK)) - $password = ConvertTo-SecureString $env:WIN_CSC_KEY_PASSWORD -AsPlainText -Force - $cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $password - if (-not $cert.Thumbprint) { throw "the organization PFX did not import" } - $files = @( - "${{ github.workspace }}\dist\redrob-windows-arm64\bin\redrob.exe", - "${{ github.workspace }}\dist\redrob-windows-x64\bin\redrob.exe", - "${{ github.workspace }}\dist\redrob-windows-x64-baseline\bin\redrob.exe" - ) - foreach ($file in $files) { - # Timestamped, so the signature outlives the certificate's own validity. - $result = Set-AuthenticodeSignature -FilePath $file -Certificate $cert ` - -HashAlgorithm SHA256 -TimestampServer http://timestamp.digicert.com - if (-not $result.SignerCertificate) { - throw "Authenticode signing failed for ${file}: $($result.StatusMessage)" - } - } - } finally { - Remove-Item $pfx -Force -ErrorAction SilentlyContinue - } - - # Reads the signature back off the artifacts rather than trusting the signing step's - # exit code. This is what makes "signed" in the release notes a checked claim: the - # previous release said Windows was signed while nothing had signed it. - - name: Verify the Windows signatures - id: verify - continue-on-error: true - if: steps.config.outputs.method != 'none' - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $files = @( - "${{ github.workspace }}\dist\redrob-windows-arm64\bin\redrob.exe", - "${{ github.workspace }}\dist\redrob-windows-x64\bin\redrob.exe", - "${{ github.workspace }}\dist\redrob-windows-x64-baseline\bin\redrob.exe" - ) - foreach ($file in $files) { - $sig = Get-AuthenticodeSignature $file - if (-not $sig.SignerCertificate) { - throw "no Authenticode signer certificate on ${file}" - } - # A runner does not trust the organization's own CA chain, so `NotTrusted` and - # `UnknownError` are expected for the PFX path and mean "signed, chain not - # verifiable here" rather than "unsigned". Trusted Signing must be outright Valid. - $accepted = if ("${{ steps.config.outputs.method }}" -eq "azure") { @("Valid") } else { @("Valid", "UnknownError", "NotTrusted") } - if ($accepted -notcontains $sig.Status.ToString()) { - throw "unexpected signature status on ${file}: $($sig.Status) $($sig.StatusMessage)" - } - Write-Host "$([IO.Path]::GetFileName($file)): $($sig.Status), signer $($sig.SignerCertificate.Subject)" - } - - - name: Record the signing outcome - id: result - if: always() - shell: bash - run: | - set -euo pipefail - method="${{ steps.config.outputs.method }}" - case "$method" in - azure) signed="${{ steps.sign-azure.outcome }}" ;; - pfx) signed="${{ steps.sign-pfx.outcome }}" ;; - *) signed="skipped" ;; - esac - # Both the signing step AND the read-back have to succeed. Either alone can be - # green while the artifacts are unsigned. - if [ "$signed" = "success" ] && [ "${{ steps.verify.outcome }}" = "success" ]; then - echo "signed=true" >> "$GITHUB_OUTPUT" - echo "::notice::Windows binaries signed via $method and verified" - else - echo "signed=false" >> "$GITHUB_OUTPUT" - echo "::warning::the Windows binaries in this release are NOT signed" - fi + files: ${{ env.REDROB_WINDOWS_BINARIES }} + description: Redrob Code + url: https://github.com/${{ github.repository }} + endpoint: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + account: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT }} + cert-profile: ${{ secrets.ARTIFACT_SIGNING_CERT_PROFILE }} # ── The installer ───────────────────────────────────────────────────────────────────── # - # Built HERE rather than in `build`, and specifically after the signing steps above, because + # Built HERE rather than in `build`, and specifically after the signing step above, because # the installer embeds the binary: building it earlier would wrap an unsigned redrob.exe in a # signed installer, so Windows would accept the download and then ship an unsigned program. - # Signing is therefore two-stage -- payload first, wrapper second -- and both stages are read - # back before the release notes are allowed to claim either. + # Signing is therefore two-stage -- payload first, wrapper second. - name: Build the Windows installer - id: installer - # continue-on-error, like every other step in this job. The installer is an ADDITION to the - # release; a failure here must not stop the binaries being repacked and uploaded. The first - # run proved the point -- the build failed and took Repack and the upload with it, so a - # missing installer would have meant no Windows assets at all. - continue-on-error: true shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -488,91 +335,25 @@ jobs: if (-not (Test-Path $setup)) { throw "the installer was not produced at $setup" } Write-Host "built $((Get-Item $setup).Length) bytes" - - name: Sign the installer with Azure Trusted Signing - id: sign-installer-azure - continue-on-error: true - if: steps.config.outputs.method == 'azure' - uses: azure/artifact-signing-action@b443cf8ea4124818d2ea9f043cba29fc3ec47b16 # v1.2.0 + # A fresh login right before the second signing point, for the same reason as the first: + # the installer build above takes minutes, and the credential must not be assumed to have + # outlived it. + - name: Log in to Azure again (OIDC) + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3 with: - endpoint: ${{ env.AZURE_ARTIFACT_SIGNING_ENDPOINT }} - signing-account-name: ${{ env.AZURE_ARTIFACT_SIGNING_ACCOUNT }} - certificate-profile-name: ${{ env.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE }} - files: | - ${{ github.workspace }}\dist\redrob-code-x64-setup.exe - exclude-environment-credential: true - exclude-workload-identity-credential: true - exclude-managed-identity-credential: true - exclude-shared-token-cache-credential: true - exclude-visual-studio-credential: true - exclude-visual-studio-code-credential: true - exclude-azure-cli-credential: false - exclude-azure-powershell-credential: true - exclude-azure-developer-cli-credential: true - exclude-interactive-browser-credential: true - - - name: Sign the installer with the organization Authenticode certificate - id: sign-installer-pfx - continue-on-error: true - if: steps.config.outputs.method == 'pfx' - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $pfx = Join-Path $env:RUNNER_TEMP "redrob-installer-signing.pfx" - try { - [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WIN_CSC_LINK)) - $password = ConvertTo-SecureString $env:WIN_CSC_KEY_PASSWORD -AsPlainText -Force - $cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $password - if (-not $cert.Thumbprint) { throw "the organization PFX did not import" } - $result = Set-AuthenticodeSignature ` - -FilePath "${{ github.workspace }}\dist\redrob-code-x64-setup.exe" ` - -Certificate $cert -HashAlgorithm SHA256 -TimestampServer http://timestamp.digicert.com - if (-not $result.SignerCertificate) { - throw "Authenticode signing failed for the installer: $($result.StatusMessage)" - } - } finally { - Remove-Item $pfx -Force -ErrorAction SilentlyContinue - } - - # Read back, on the same terms as the binaries: an unsigned installer is the one file a user - # actually double-clicks, so it is the last thing that should be trusted on an exit code. - - name: Verify the installer signature - id: verify-installer - continue-on-error: true - if: steps.config.outputs.method != 'none' - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $setup = "${{ github.workspace }}\dist\redrob-code-x64-setup.exe" - $sig = Get-AuthenticodeSignature $setup - if (-not $sig.SignerCertificate) { throw "no Authenticode signer certificate on the installer" } - $accepted = if ("${{ steps.config.outputs.method }}" -eq "azure") { @("Valid") } else { @("Valid", "UnknownError", "NotTrusted") } - if ($accepted -notcontains $sig.Status.ToString()) { - throw "unexpected signature status on the installer: $($sig.Status) $($sig.StatusMessage)" - } - Write-Host "redrob-code-x64-setup.exe: $($sig.Status), signer $($sig.SignerCertificate.Subject)" - - - name: Record the installer signing outcome - id: installer-result - if: always() - shell: bash - run: | - set -euo pipefail - method="${{ steps.config.outputs.method }}" - case "$method" in - azure) signed="${{ steps.sign-installer-azure.outcome }}" ;; - pfx) signed="${{ steps.sign-installer-pfx.outcome }}" ;; - *) signed="skipped" ;; - esac - if [ "${{ steps.installer.outcome }}" != "success" ]; then - echo "installer=false" >> "$GITHUB_OUTPUT" - echo "::warning::the Windows installer was not built" - elif [ "$signed" = "success" ] && [ "${{ steps.verify-installer.outcome }}" = "success" ]; then - echo "installer=true" >> "$GITHUB_OUTPUT" - echo "::notice::Windows installer signed via $method and verified" - else - echo "installer=false" >> "$GITHUB_OUTPUT" - echo "::warning::the Windows installer in this release is NOT signed" - fi + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign the installer + uses: redrob-labs/.github/actions/sign-windows@main + with: + files: ${{ env.REDROB_WINDOWS_INSTALLER }} + description: Redrob Code + url: https://github.com/${{ github.repository }} + endpoint: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + account: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT }} + cert-profile: ${{ secrets.ARTIFACT_SIGNING_CERT_PROFILE }} - name: Repack shell: bash @@ -586,19 +367,23 @@ jobs: - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: cli-windows-signed + name: release-windows path: | dist/redrob-windows-*.zip dist/redrob-code-x64-setup.exe + if-no-files-found: error - publish: + # The assets only this repository knows how to shape: the Linux archives, the Redrob Launcher + # copies, the `VERSION` marker and the build provenance. Checksums, the launcher manifest and the + # release itself are the organization's job (`publish` below). + assets: needs: [version, build, sign-macos, sign-windows] runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - ref: develop - fetch-depth: 0 + ref: ${{ needs.version.outputs.sha }} + fetch-depth: 1 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: @@ -607,17 +392,14 @@ jobs: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: cli-macos-signed - path: assets - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: cli-windows-signed - path: assets + pattern: release-* + path: signed + merge-multiple: true - name: Pack the Linux binaries run: | set -euo pipefail + mkdir -p assets cd unsigned for directory in redrob-linux-*; do [ -d "$directory" ] || continue @@ -633,30 +415,29 @@ jobs: tar -czf "../assets/${directory}.tar.gz" -C "$directory/bin" . done - - name: Checksums - working-directory: assets + # Redrob Launcher installs only `---[-portable].` with its own + # os-arch spellings (`linux-x86_64`, `macos-aarch64`), which none of the names above are. The + # names above stay -- install.sh, `redrob upgrade`, Homebrew, AUR and the Dockerfile download + # them verbatim -- and the launcher's are added beside them. See the script's header for the + # layout inside; `test/installation/launcher-assets.test.ts` guards both. + # + # Bun alone, without the repository's dependencies: the script imports nothing but Bun and + # Node built-ins, and `bun install` here would cost minutes for nothing. + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: package.json + + - name: Add the Redrob Launcher assets + env: + VERSION: ${{ needs.version.outputs.version }} run: | set -euo pipefail - # The installer is included deliberately: it is the file a Windows user actually - # double-clicks, so it is the one that most needs a published digest. Guarded with a - # glob test rather than assumed, because the installer step is `continue-on-error` - # and a release without it must still produce checksums for everything else. - set -- redrob-code-x64-setup.exe - if [ -e "$1" ]; then - sha256sum redrob-*.zip redrob-*.tar.gz redrob-code-x64-setup.exe > SHA256SUMS - else - echo "::warning::no installer present; SHA256SUMS will not cover one" - sha256sum redrob-*.zip redrob-*.tar.gz > SHA256SUMS - fi - cat SHA256SUMS + bun ./packages/redrob/script/launcher-assets.ts \ + --version "$VERSION" --unsigned unsigned --signed signed --out assets # The marker install.sh and `redrob upgrade` read to answer "what is the newest - # version". Written AFTER the checksums so it is deliberately not one of the files - # SHA256SUMS covers: it names a version rather than carrying a payload, and the - # archive it points at is checksum-verified once fetched. - # - # `releases/latest/download/VERSION` resolves to whichever release is newest, so no - # client has to hardcode a tag and nothing here consults the GitHub API, which is + # version". `releases/latest/download/VERSION` resolves to whichever release is newest, + # so no client has to hardcode a tag and nothing here consults the GitHub API, which is # rate limited to 60 requests an hour for an anonymous caller. - name: Write the latest-version marker working-directory: assets @@ -671,98 +452,46 @@ jobs: uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | + signed/redrob-*.zip assets/redrob-*.zip assets/redrob-*.tar.gz - - name: Create the release - if: ${{ !inputs.dry_run }} + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-linux-and-launcher + path: assets/* + if-no-files-found: error + + # The release is dispatch-driven, so the workflow creates the tag itself: `publish` below + # releases exactly this tag at exactly the commit `version` resolved. + tag: + needs: [version, build, sign-macos, sign-windows, assets] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ needs.version.outputs.sha }} + fetch-depth: 0 + + - name: Create and push the tag env: - GH_TOKEN: ${{ github.token }} TAG: ${{ needs.version.outputs.tag }} UPSTREAM: ${{ needs.version.outputs.upstream }} - WINDOWS_SIGNED: ${{ needs.sign-windows.outputs.signed }} - WINDOWS_INSTALLER: ${{ needs.sign-windows.outputs.installer }} - WINDOWS_SIGNING_METHOD: ${{ needs.sign-windows.outputs.method }} run: | set -euo pipefail # An annotated tag records a tagger, and a bare runner has no git identity, so # `git tag -a` fails with "empty ident name" AFTER the build, the signing and the # notarization have all succeeded -- the most expensive possible place to # discover it. The tag stays annotated rather than lightweight because it is what - # a release points at and it should say who made it and when. + # a release points at and it should say who made it and when, and its message + # carries the upstream version the release notes no longer state. git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - { - echo "Built on [opencode v$UPSTREAM](https://github.com/anomalyco/opencode/releases/tag/v$UPSTREAM)." - echo - echo "Redrob Code has its own version line. This number is not upstream's and does" - echo "not track it: what this build is based on is recorded in \`UPSTREAM_VERSION\`" - echo "and stated above. See \`docs/VERSIONING.md\`." - echo - echo "## Installing" - echo - echo "macOS and Linux:" - echo - echo '```sh' - echo "curl -fsSL https://console.redrob.ai/code/install.sh | sh" - echo '```' - echo - echo "Windows, in PowerShell:" - echo - echo '```powershell' - echo "irm https://console.redrob.ai/code/install.ps1 | iex" - echo '```' - echo - echo "These releases are the only place builds are published. There is no CDN" - echo "mirror, and an install from before 0.1.0 does not upgrade into this line:" - echo "run the command above again to move onto it." - echo - echo "## Verifying" - echo - echo "macOS binaries are signed with a Developer ID certificate and notarized." - if [ "$WINDOWS_INSTALLER" = "true" ]; then - echo "Windows has a signed installer: download \`redrob-code-x64-setup.exe\` and run it." - echo "It installs to \`%LOCALAPPDATA%\\Redrob\\bin\`, adds that to your user PATH, and" - echo "needs no Administrator rights. Open a NEW terminal afterwards -- an already-open" - echo "one still holds the old PATH." - echo - fi - if [ "$WINDOWS_SIGNED" = "true" ]; then - # Named from what the run actually did. An unrecognised value is described - # without naming a method rather than guessed at: a wrong provenance claim is - # worse than a vaguer true one, because a reader cannot tell it is wrong. - case "${WINDOWS_SIGNING_METHOD:-}" in - azure) - echo "Windows binaries are signed through Azure Trusted Signing." - ;; - pfx) - echo "Windows binaries are signed with the Redrob Authenticode certificate." - ;; - *) - echo "Windows binaries are signed. Verify the signature with \`signtool verify /pa\`" - echo "or against the provenance attestation below." - ;; - esac - else - echo "**Windows binaries in this release are NOT signed**, so Windows will show a" - echo "SmartScreen warning on first run. Verify the archive against \`SHA256SUMS\`" - echo "and the provenance attestation below before running it." - fi - echo - echo "Every archive carries build provenance attesting it was produced by this" - echo "workflow, which a code signature alone does not establish -- a signature says" - echo "the key was used, not where:" - echo - echo '```sh' - echo "gh attestation verify redrob-darwin-arm64.zip --repo ${{ github.repository }}" - echo '```' - echo - echo "Checksums are in \`SHA256SUMS\`." - } > notes.md git tag -a "$TAG" -m "Redrob Code $TAG (built on opencode v$UPSTREAM)" 2>/dev/null || \ echo "tag $TAG already exists locally" # A release that fails after tagging must be retryable. Without this a single - # transient failure in the steps below leaves the version permanently unusable: + # transient failure in the publish job leaves the version permanently unusable: # the tag exists, so every retry dies on push before reaching the release. # # The tag is named explicitly. `--tags` and `--follow-tags` would push whatever @@ -770,59 +499,7 @@ jobs: # decided to publish ends up published. git push origin "refs/tags/$TAG" 2>/dev/null || echo "tag $TAG is already on the remote" - # The release is created EMPTY and the assets are uploaded one at a time, each - # retried. `gh release create "$TAG" assets/*` uploads all twenty in one command and - # rolls the whole release back when any single one fails, so a transient 5xx on the - # nineteenth asset discards a run that has already spent forty minutes building, - # signing and notarizing. That is not hypothetical: v0.1.0 lost two consecutive - # attempts that way, to `504 Gateway Timeout` and `500 Error creating asset temp dir` - # on different assets. - # - # Splitting it also collapses the create and re-run paths into one: an existing - # release has its notes replaced and its assets clobbered, which is what a retry - # needs anyway. - if gh release view "$TAG" >/dev/null 2>&1; then - echo "::notice::release $TAG exists; replacing its notes and assets" - gh release edit "$TAG" --notes-file notes.md - else - gh release create "$TAG" --title "$TAG" --notes-file notes.md - fi - - failed=() - for asset in assets/*; do - [ -f "$asset" ] || continue - uploaded=false - for attempt in 1 2 3; do - if gh release upload "$TAG" "$asset" --clobber; then - uploaded=true - break - fi - echo "::warning::upload of $(basename "$asset") failed on attempt $attempt" - sleep $((attempt * 10)) - done - if [ "$uploaded" = false ]; then - failed+=("$(basename "$asset")") - fi - done - - if [ ${#failed[@]} -ne 0 ]; then - echo "::error::these assets did not upload after three attempts: ${failed[*]}" - echo "The release and tag exist, so re-running this job resumes rather than restarts." - exit 1 - fi - - # A release missing an archive installs nothing on that platform while looking - # published, so the asset list is checked rather than assumed. - published="$(gh release view "$TAG" --json assets --jq '.assets | length')" - expected="$(find assets -maxdepth 1 -type f | wc -l | tr -d ' ')" - if [ "$published" != "$expected" ]; then - echo "::error::release $TAG has $published assets, expected $expected" - exit 1 - fi - echo "::notice::release $TAG published with $published assets" - - name: Point at the promotion to main - if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.version.outputs.tag }} @@ -836,11 +513,10 @@ jobs: # can approve its own, which is worth more than the convenience of not clicking # a link. So this prints the link and a person opens it. # - # Compared against the remote rather than a local ref: this job checks out - # `develop` alone, so `origin/main` does not exist here and a - # `git log origin/main..HEAD` would come back empty and read as "nothing to - # promote" -- passing for the wrong reason, which is how the previous version of - # this step silently did nothing. + # Compared against the remote rather than a local ref: this job checks out one + # commit, so `origin/main` does not exist here and a `git log origin/main..HEAD` + # would come back empty and read as "nothing to promote" -- passing for the wrong + # reason, which is how the previous version of this step silently did nothing. main_sha="$(git ls-remote origin refs/heads/main | cut -f1)" if [ -z "$main_sha" ]; then echo "::notice::no main branch yet; create it from $TAG to start tracking released state" @@ -854,9 +530,27 @@ jobs: { echo "### Promote \`develop\` to \`main\` for $TAG" echo - echo "The release is published. \`main\` still points at \`${main_sha:0:9}\`, so what" + echo "The release is being published. \`main\` still points at \`${main_sha:0:9}\`, so what" echo "the repository says is *released* has not moved yet." echo echo "[Open the promotion pull request]($url)" } >> "$GITHUB_STEP_SUMMARY" - echo "::notice::release published; promote develop to main at $url" + echo "::notice::promote develop to main at $url" + + # Checksums (`SHA256SUMS.txt`), the launcher manifest (`redrob-launcher.json`) and the release + # itself, one way for every redrob-labs repository. Published as latest; a version carrying a `-` + # is a prerelease. Not on a dry run, and not when any build job failed, was cancelled or skipped. + # The tag is passed explicitly because this workflow runs from a dispatch, not from a tag push. + publish: + needs: [version, build, sign-macos, sign-windows, assets, tag] + if: always() && !inputs.dry_run && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && !contains(needs.*.result, 'skipped') + uses: redrob-labs/.github/.github/workflows/release-publish.yml@main + permissions: + contents: write + with: + version: ${{ needs.version.outputs.version }} + title: Redrob Code + launcher_manifest: true + publish: true + tag: ${{ needs.version.outputs.tag }} + target: ${{ needs.version.outputs.sha }} diff --git a/.github/workflows/tag-hygiene.yml b/.github/workflows/tag-hygiene.yml new file mode 100644 index 0000000000..2e59f43ad4 --- /dev/null +++ b/.github/workflows/tag-hygiene.yml @@ -0,0 +1,68 @@ +name: tag-hygiene + +# This repository's own rule, kept beside the shared gitflow caller (`gitflow.yml`, which only calls +# redrob-labs/.github): no workflow may push tags in bulk or refill refs/tags from upstream. +# +# It lives in its own file because `gitflow.yml` is a verbatim copy of the organization template and +# must stay one. A rule nothing checks is a preference, so this one runs on every pull request. + +on: + pull_request: + # No branch filter. A filter that omits the default branch silently stops running, which is exactly + # what happened in a sibling repository when the default moved to `develop` and three workflows + # kept filtering on `[main, master]`. + types: [opened, reopened, edited, synchronize] + workflow_dispatch: + +permissions: + contents: read + +jobs: + tag-hygiene: + name: no workflow pushes tags in bulk + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 1 + + - name: Refuse bulk tag pushes and unnamespaced upstream fetches + run: | + set -euo pipefail + + # This fork inherited over a thousand upstream tags, which buried its own releases and + # made a `v*` glob deletion unusable. They were deleted. Two command shapes would undo + # that in one run, so neither may appear in a workflow: + # + # a tag push with no tag named republishes whatever is in refs/tags + # a tag fetch from `upstream` refills refs/tags from upstream + # + # The release workflow pushes exactly one tag, named in full as `refs/tags/$TAG`, and + # that is the only allowed shape. Fetching OUR OWN tags from `origin` is fine and is not + # matched: the release-tagging actions depend on it. See docs/VERSIONING.md. + # + # Comment lines are stripped before matching. Without that this gate fails on the very + # comment above, which names both banned shapes in order to explain them. + bad=0 + while IFS= read -r file; do + body="$(sed 's/[[:space:]]*#.*$//' "$file")" + + if printf '%s\n' "$body" | grep -nE 'git[[:space:]]+push[^|&;]*--(tags|follow-tags)'; then + echo "::error file=$file::pushes tags in bulk; push one tag by its full refs/tags/" + bad=1 + fi + + # Only upstream's tags are the problem, and only when no refspec redirects them out + # of refs/tags. + if printf '%s\n' "$body" \ + | grep -E 'git[[:space:]]+fetch[^|&;]*--tags[^|&;]*upstream|git[[:space:]]+fetch[^|&;]*upstream[^|&;]*--tags' \ + | grep -qv 'refs/upstream-tags/'; then + echo "::error file=$file::fetches upstream tags into refs/tags; use '+refs/tags/*:refs/upstream-tags/*'" + bad=1 + fi + done < <(find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \)) + + if [ "$bad" -ne 0 ]; then + exit 1 + fi + echo "no bulk tag pushes and no unnamespaced upstream tag fetches" diff --git a/AGENTS.md b/AGENTS.md index deb58794c3..e38dd54fbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,33 +14,32 @@ before editing a package** — `packages/redrob/`, `packages/redrob/test/`, `pac Gitflow. **`develop` is the default branch and the base of every pull request.** -Branch names come from `docs/BRANCHING.md` and nothing else: `feature/`, -`sync/upstream-`, `hotfix/`, `release/`. Do not invent a prefix, and in -particular do not name a branch after the tool or agent that made it: a branch name says what the -change is, not who typed it. - -The table has no name for the back-merge that closes a hotfix, which is part of why that step gets -skipped. Until it does, use `hotfix/-backmerge` and say in the pull request that it carries -`main` into `develop`. - -- **`develop`** integrates. Release from it: the `release` workflow builds, signs, tags, and then - prints a link to open the promotion pull request from `develop` into `main`. +Branch names are `/` with the type from the redrob-labs organization list: +`feat fix chore docs test refactor perf ci build sync hotfix release` (`docs/BRANCHING.md` has the +ones this repository actually uses: `feat/`, `sync/upstream-`, `hotfix/`, +`release/`). Do not invent a prefix, and in particular do not name a branch after the tool +or agent that made it: a branch name says what the change is, not who typed it. + +- **`develop`** integrates. Release from it: the `release` workflow builds, signs, tags, publishes + the release, and prints a link to open the promotion pull request from `develop` into `main`. - **`main`** is released state. It moves through a reviewed pull request, not an automated push. - **Hotfix**: branch from `main`, merge into `main`, release, **then merge `main` back into `develop`**. This is the step that gets skipped. #13 fixed the lockfile on `main`, the back-merge never happened, and for a month `bun install --frozen-lockfile` failed on the default branch. - `branch-sync.yml` now fails on push to `main` while `main` holds a commit `develop` does not, so - this is checked rather than remembered. + The shared gitflow workflow now opens the back-merge pull request (`sync/back-merge-`) on + every push to `main` that leaves `main` ahead of `develop`, so this is checked rather than + remembered. - Both branches are protected: pull requests only, force pushes and deletions blocked, zero required reviews, admin enforcement off. Required checks: `typecheck`, `unit (linux)`, `core (linux)`, `httpapi (linux)`. -Both rules above are enforced by CI, not by memory. `gitflow.yml` runs on every pull request and -fails when the head branch prefix is not one of the four types, because a prefix nobody defined -(`kiro/`) reached this repository while the rule sat in prose. `branch-sync.yml` runs on every push -to `main` and owns the back-merge rule; the two are separate workflows because a branch name can -only be judged once a pull request exists, while a missing back-merge can only be judged after -`main` has moved. +Both rules above are enforced by CI, not by memory. `gitflow.yml` is a verbatim copy of the +organization's caller template and only calls `redrob-labs/.github/.github/workflows/gitflow.yml`, +which owns both checks: `gitflow / branch name follows the convention` on every pull request +(because a prefix nobody defined, `kiro/`, reached this repository while the rule sat in prose) and +`gitflow / main is contained in develop` on every push to `main`. Do not add a type list to the +caller; the one list lives in the organization repository. This repository's own tag rule (no +workflow pushes tags in bulk) is `tag-hygiene.yml`. The invariant, readable by hand: diff --git a/bun.lock b/bun.lock index 2774c1a1fe..94ec851482 100644 --- a/bun.lock +++ b/bun.lock @@ -372,8 +372,10 @@ "cross-spawn": "catalog:", "decimal.js": "10.5.0", "diff": "catalog:", + "docx": "9.8.1", "drizzle-orm": "catalog:", "effect": "catalog:", + "exceljs": "4.4.0", "fuzzysort": "3.1.0", "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", @@ -391,6 +393,8 @@ "opencode-poe-auth": "0.0.1", "opentui-spinner": "catalog:", "partial-json": "0.1.7", + "pdf-lib": "1.17.1", + "pptxgenjs": "4.0.1", "remeda": "catalog:", "semver": "^7.6.3", "solid-js": "catalog:", @@ -852,8 +856,6 @@ "@clack/prompts": ["@clack/prompts@1.0.0-alpha.1", "", { "dependencies": { "@clack/core": "1.0.0-alpha.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-07MNT0OsxjKOcyVfX8KhXBhJiyUbDP1vuIAcHc+nx5v93MJO23pX3X/k3bWz6T3rpM9dgWPq90i4Jq7gZAyMbw=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="], - "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="], @@ -916,6 +918,10 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A=="], + + "@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA=="], + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.6", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^4.0.0" } }, "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA=="], "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], @@ -1232,6 +1238,10 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="], + + "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="], + "@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="], "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], @@ -1658,12 +1668,18 @@ "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], + "body-parser": ["body-parser@1.20.6", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g=="], "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], @@ -1684,6 +1700,8 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="], + "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], @@ -1750,6 +1768,8 @@ "compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], @@ -1788,6 +1808,8 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decimal.js": ["decimal.js@10.5.0", "", {}, "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="], @@ -1826,6 +1848,8 @@ "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], + "docx": ["docx@9.8.1", "", { "dependencies": { "@types/node": "^26.6.2", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^6.0.1", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-bv4yDxgiV9aRyh327HG+V36wG0f23wk32SDhXGQpUhnykVtIPaR1K4ISi8E+DYJ7OcRQRkR8Y70QYlSS08V1cA=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -1842,6 +1866,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -1856,6 +1882,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="], "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], @@ -1904,6 +1932,8 @@ "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "exceljs": ["exceljs@4.4.0", "", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg=="], + "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=="], "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], @@ -1920,6 +1950,8 @@ "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + "fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1974,10 +2006,14 @@ "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], @@ -2042,6 +2078,8 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], @@ -2066,6 +2104,8 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="], + "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=="], @@ -2080,8 +2120,14 @@ "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], @@ -2170,7 +2216,7 @@ "is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="], - "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], @@ -2220,6 +2266,8 @@ "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + "jszip": ["jszip@3.10.2", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ=="], + "just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="], "just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="], @@ -2236,26 +2284,52 @@ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], + "listenercount": ["listenercount@1.0.1", "", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="], + "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], + + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + + "lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="], + + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + + "lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="], + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + "lodash.isnil": ["lodash.isnil@4.0.0", "", {}, "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="], + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + "lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="], + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "lodash.union": ["lodash.union@4.6.0", "", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="], + + "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -2300,6 +2374,8 @@ "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -2332,6 +2408,8 @@ "nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="], + "nanoid": ["nanoid@6.0.1", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], @@ -2436,6 +2514,8 @@ "pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -2448,6 +2528,8 @@ "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -2458,6 +2540,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="], + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -2484,6 +2568,8 @@ "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "pptxgenjs": ["pptxgenjs@4.0.1", "", { "dependencies": { "@types/node": "^22.8.1", "https": "^1.0.0", "image-size": "^1.2.1", "jszip": "^3.10.1" } }, "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A=="], + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], @@ -2514,6 +2600,8 @@ "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -2528,7 +2616,7 @@ "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], - "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], @@ -2592,6 +2680,10 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="], + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], @@ -2616,6 +2708,8 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -2736,6 +2830,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="], @@ -2808,6 +2904,8 @@ "unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="], + "unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="], + "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -2866,8 +2964,14 @@ "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], + + "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -3200,10 +3304,14 @@ "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.10.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw=="], + "archiver/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "archiver-utils/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "axios/form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -3212,22 +3320,36 @@ "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "compress-commons/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "crc32-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "engine.io-client/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "es-get-iterator/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "exceljs/archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="], + + "exceljs/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -3242,6 +3364,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "fstream/rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="], + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], @@ -3250,6 +3374,8 @@ "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "light-my-request/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -3284,6 +3410,8 @@ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "pdf-lib/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "redrob/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], @@ -3292,6 +3420,10 @@ "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -3318,18 +3450,24 @@ "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-glcEJC2mBXJKj7joFI0fRhcbdDYKTBgXMPcT6Vcnlym67tTzuNG9pFx3zblxVv8TdOxhojJja5zGG19yeGJxuA=="], "venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="], + "which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "zip-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "@actions/artifact/@actions/core/@actions/exec": ["@actions/exec@2.0.0", "", { "dependencies": { "@actions/io": "^2.0.0" } }, "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw=="], "@actions/core/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], @@ -3562,13 +3700,29 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "exceljs/archiver/archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="], + + "exceljs/archiver/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "exceljs/archiver/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "exceljs/archiver/zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="], + "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "fstream/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -3596,6 +3750,10 @@ "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "unzipper/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], "venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -3672,6 +3830,16 @@ "babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "exceljs/archiver/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "exceljs/archiver/archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "exceljs/archiver/zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], + + "exceljs/archiver/zip-stream/compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg=="], + + "fstream/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "redrob/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -3688,6 +3856,30 @@ "babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "exceljs/archiver/archiver-utils/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "exceljs/archiver/archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "exceljs/archiver/archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "exceljs/archiver/zip-stream/archiver-utils/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "exceljs/archiver/zip-stream/compress-commons/crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="], + + "fstream/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "exceljs/archiver/archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], + + "exceljs/archiver/zip-stream/archiver-utils/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "fstream/rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "exceljs/archiver/archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "exceljs/archiver/zip-stream/archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], + + "exceljs/archiver/zip-stream/archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/crates/engine-client/Cargo.lock b/crates/engine-client/Cargo.lock new file mode 100644 index 0000000000..85de0b8881 --- /dev/null +++ b/crates/engine-client/Cargo.lock @@ -0,0 +1,359 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "redrob-engine-client" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "dirs", + "log", + "serde", + "serde_json", + "tempfile", + "ureq", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/engine-client/Cargo.toml b/crates/engine-client/Cargo.toml new file mode 100644 index 0000000000..1fd284ad30 --- /dev/null +++ b/crates/engine-client/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "redrob-engine-client" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +license = "MIT" +repository = "https://github.com/redrob-labs/redrob-code" +description = "Find, start, watch and stop the shared Redrob Code daemon from a Rust app: the record in ~/.redrob/run/server.json, its health, who is attached, and the engine's OpenAI-compatible base" +# Lives beside the engine contract it implements (docs/LOCAL-ENGINE-API.md, section 3) so that +# every Rust Redrob app (Shape, Canvas, Query, Recall, the launcher) reaches the one daemon the +# same way. Apps depend on it as a git dependency pinned to the engine tag they target: +# redrob-engine-client = { git = "https://github.com/redrob-labs/redrob-code", tag = "v0.6.3" } +# No crates.io publish yet; flip `publish` when a registry token exists. +publish = false + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +log = "0.4" +dirs = "6" +base64 = "0.22" +# Plain HTTP to 127.0.0.1 only: the daemon never listens elsewhere, so no TLS is built in. +ureq = { version = "3.4", default-features = false } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/engine-client/examples/live.rs b/crates/engine-client/examples/live.rs new file mode 100644 index 0000000000..393bbf70a0 --- /dev/null +++ b/crates/engine-client/examples/live.rs @@ -0,0 +1,18 @@ +//! Live check against a real daemon: require it, print the base and the headers an app would send. +//! Run with `REDROB_CODE_BIN= REDROB_RUN_DIR= cargo run --example live`. +fn main() { + let record = match redrob_engine_client::require_as("shape", 1) { + Ok(r) => r, + Err(e) => { + eprintln!("unavailable: {e}"); + std::process::exit(2); + } + }; + println!("base={}", redrob_engine_client::openai_base(&record)); + println!("pid={} version={}", record.pid, record.version); + for (k, v) in redrob_engine_client::app_headers(&record, "shape", Some("doc-1")) { + println!("{k}: {}", if k == "Authorization" { "Basic …".to_string() } else { v }); + } + let clients = redrob_engine_client::clients(&record).expect("clients"); + println!("attached={}", clients.clients.len()); +} diff --git a/crates/engine-client/rustfmt.toml b/crates/engine-client/rustfmt.toml new file mode 100644 index 0000000000..3121924d65 --- /dev/null +++ b/crates/engine-client/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 150 +use_small_heuristics = "Max" diff --git a/crates/engine-client/src/lib.rs b/crates/engine-client/src/lib.rs new file mode 100644 index 0000000000..32b1f28361 --- /dev/null +++ b/crates/engine-client/src/lib.rs @@ -0,0 +1,335 @@ +//! The shared Redrob Code daemon, from a Rust app. +//! +//! One `redrob serve --register` runs per user and every Redrob app attaches to it (the engine's +//! `docs/LOCAL-ENGINE-API.md`, section 3). The daemon leaves its record at +//! `$HOME/.redrob/run/server.json` (mode 0600): url, port, pid, version and the Basic secret, drawn +//! fresh on every start. This crate reads that record, checks the daemon answers with it, lists who +//! is attached, starts one through `redrob service start --json` when none answers, and stops it. +//! +//! Nothing here reads the engine's configuration or its credentials: the daemon's record is the +//! only file touched, and the only process reached is the one it names. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + +/// The record `redrob serve --register` writes, and `redrob service start --json` prints. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + /// Random per start; a different id means the daemon restarted. + pub id: String, + pub version: String, + pub url: String, + pub port: u16, + pub pid: u32, + /// The Basic password (username `redrob`). Never log it. + pub secret: String, +} + +impl std::fmt::Display for Record { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (pid {}, v{})", self.url, self.pid, self.version) + } +} + +/// One app attached to the daemon. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Client { + pub id: String, + pub app: String, + #[serde(default)] + pub pid: Option, + #[serde(rename = "attachedAt")] + pub attached_at: u64, + #[serde(rename = "lastSeen")] + pub last_seen: u64, +} + +/// `GET /api/clients`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Clients { + pub clients: Vec, + #[serde(rename = "ttlMs")] + pub ttl_ms: u64, + /// How long nobody has been attached, in ms; absent while someone is. + #[serde(rename = "idleForMs", default)] + pub idle_for_ms: Option, + /// The daemon exits after this long with nobody attached; absent when it stays up. + #[serde(rename = "idleExitAfterMs", default)] + pub idle_exit_after_ms: Option, +} + +/// `$HOME/.redrob/run`, or `REDROB_RUN_DIR`. +pub fn run_dir() -> Option { + if let Some(d) = std::env::var_os("REDROB_RUN_DIR").filter(|v| !v.is_empty()) { + return Some(PathBuf::from(d)); + } + dirs::home_dir().map(|h| h.join(".redrob").join("run")) +} + +pub fn record_path() -> Option { + run_dir().map(|d| d.join("server.json")) +} + +/// The record on disk, or `None` when there is none or it does not parse. +pub fn read_record() -> Option { + read_record_at(&record_path()?) +} + +pub fn read_record_at(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +/// The `Authorization` header value for the daemon. +pub fn authorization(secret: &str) -> String { + format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("redrob:{secret}"))) +} + +/// Where the OpenAI-compatible route lives: `/v1` (chat completions is `/v1/chat/completions`). +pub fn openai_base(record: &Record) -> String { + format!("{}/v1", record.url.trim_end_matches('/')) +} + +/// The request header that names the calling app on the shared daemon. +pub const APP_HEADER: &str = "x-redrob-app"; +/// The request header that names the caller's own session on the shared daemon. +pub const SESSION_HEADER: &str = "x-redrob-session"; + +/// Headers an app puts on every `/v1/chat/completions` call: the daemon's `Authorization`, the app +/// name, and (when the app has one) its session id. The engine forwards the labels to the provider +/// under the names its own agent runner uses, so the Console groups both paths alike. One daemon +/// serves every app, so the app name must travel on the request -- the daemon's environment names +/// nobody. +pub fn app_headers(record: &Record, app: &str, session: Option<&str>) -> Vec<(&'static str, String)> { + let mut headers = vec![("Authorization", authorization(&record.secret)), (APP_HEADER, app.to_string())]; + if let Some(session) = session.map(str::trim).filter(|s| !s.is_empty()) { + headers.push((SESSION_HEADER, session.to_string())); + } + headers +} + +/// Why an app has no engine to talk to, for the message it shows. There is deliberately no +/// "fall back to the provider directly" arm: two paths would mean the user cannot tell whether +/// masking applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Unavailable { + /// No `redrob` binary: not installed. Point at the launcher or the Code install. + NotInstalled, + /// Installed, but `redrob service start` did not yield a healthy daemon. + StartFailed(String), +} + +impl std::fmt::Display for Unavailable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Unavailable::NotInstalled => write!(f, "Redrob Code is not installed; install it (the launcher does this) and try again"), + Unavailable::StartFailed(reason) => write!(f, "the Redrob Code engine could not be started: {reason}"), + } + } +} + +/// [`connect_or_start_as`] with the two failure kinds told apart, for apps that render them. +pub fn require_as(app: &str, idle_exit_minutes: u32) -> Result { + if !installed() { + return Err(Unavailable::NotInstalled); + } + connect_or_start_as(app, idle_exit_minutes).map_err(Unavailable::StartFailed) +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(3))).http_status_as_error(false).build().into() +} + +fn get_json Deserialize<'de>>(record: &Record, path: &str) -> Result { + let url = format!("{}{}", record.url.trim_end_matches('/'), path); + let mut response = agent().get(&url).header("Authorization", &authorization(&record.secret)).call().map_err(|e| e.to_string())?; + let status = response.status().as_u16(); + if status != 200 { + return Err(format!("{path} answered {status}")); + } + let text = response.body_mut().read_to_string().map_err(|e| e.to_string())?; + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +/// Whether the daemon the record names answers `/api/health` with the record's secret. +pub fn healthy(record: &Record) -> bool { + #[derive(Deserialize)] + struct Health { + healthy: bool, + } + matches!(get_json::(record, "/api/health"), Ok(Health { healthy: true })) +} + +/// Who is attached, and the idle policy. +pub fn clients(record: &Record) -> Result { + get_json(record, "/api/clients") +} + +/// The daemon as it stands: the record, if its daemon answers. +pub fn current() -> Option { + let record = read_record()?; + healthy(&record).then_some(record) +} + +/// Where the engine binary is, by the engine's own resolution order: `REDROB_CODE_BIN`, +/// `~/.redrob/bin/redrob`, then `redrob` on PATH. +pub fn redrob_bin() -> PathBuf { + if let Some(b) = std::env::var_os("REDROB_CODE_BIN").filter(|v| !v.is_empty()) { + return PathBuf::from(b); + } + if let Some(home) = dirs::home_dir() { + let name = if cfg!(windows) { "redrob.exe" } else { "redrob" }; + let p = home.join(".redrob").join("bin").join(name); + if p.is_file() { + return p; + } + } + PathBuf::from("redrob") +} + +/// Whether an engine binary can be found at all. +pub fn installed() -> bool { + let bin = redrob_bin(); + if bin.components().count() > 1 { + return bin.is_file(); + } + // Bare name: look along PATH. + let Some(path) = std::env::var_os("PATH") else { return false }; + std::env::split_paths(&path).any(|d| { + let p = d.join(&bin); + p.is_file() || (cfg!(windows) && d.join(format!("{}.exe", bin.display())).is_file()) + }) +} + +/// Pulls the one JSON line out of what `redrob service start --json` printed. +pub fn parse_record_output(stdout: &str) -> Result { + let line = stdout.lines().map(str::trim).rfind(|l| l.starts_with('{')).ok_or("the engine printed no daemon record")?; + serde_json::from_str(line).map_err(|e| format!("the engine printed a record that does not parse: {e}")) +} + +/// Find the daemon or start it: `redrob service start --json`, which holds the start and upgrade +/// logic in one place (the engine). `idle_exit_minutes`: how long a daemon started here stays up +/// with nobody attached; `0` keeps it up. The daemon is started under the launcher's name; an app +/// that wants its own name on the start uses [`connect_or_start_as`]. +pub fn connect_or_start(idle_exit_minutes: u32) -> Result { + connect_or_start_as("launcher", idle_exit_minutes) +} + +/// [`connect_or_start`], naming the app that asked (`REDROB_APP` on the `redrob service start` +/// call; the daemon itself serves every app, so per-request labels go on the request as +/// `x-redrob-app`, see [`app_headers`]). +pub fn connect_or_start_as(app: &str, idle_exit_minutes: u32) -> Result { + if let Some(record) = current() { + return Ok(record); + } + let bin = redrob_bin(); + let output = Command::new(&bin) + .args(["service", "start", "--json", "--idle-exit", &idle_exit_minutes.to_string()]) + .env("REDROB_APP", app) + .output() + .map_err(|e| format!("could not run {}: {e}. Install Redrob Code first.", bin.display()))?; + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + let out = String::from_utf8_lossy(&output.stdout); + return Err(format!("redrob service start failed ({}): {}", output.status, if err.trim().is_empty() { out.trim() } else { err.trim() })); + } + parse_record_output(&String::from_utf8_lossy(&output.stdout)) +} + +/// Stop the daemon the record names: `redrob service stop`, which signals only a daemon that +/// answered with the record's own secret, so a stale pid another process now owns is never hit. +pub fn stop() -> Result { + if read_record().is_none() { + return Ok(false); + } + let bin = redrob_bin(); + let output = Command::new(&bin).args(["service", "stop"]).output().map_err(|e| format!("could not run {}: {e}", bin.display()))?; + if !output.status.success() { + return Err(format!("redrob service stop failed ({})", output.status)); + } + // The CLI prints its one word on stderr; what matters is that the record is gone. + Ok(read_record().is_none()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> Record { + Record { id: "id".into(), version: "0.6.0".into(), url: "http://127.0.0.1:4096".into(), port: 4096, pid: 1, secret: "s3cret".into() } + } + + #[test] + fn the_record_round_trips_and_reads_from_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("server.json"); + std::fs::write(&path, serde_json::to_string(&record()).unwrap()).unwrap(); + assert_eq!(read_record_at(&path), Some(record())); + std::fs::write(&path, "{not json").unwrap(); + assert_eq!(read_record_at(&path), None); + std::fs::write(&path, r#"{"url":"x","pid":1}"#).unwrap(); + assert_eq!(read_record_at(&path), None, "a record missing fields is not a record"); + } + + #[test] + fn the_basic_header_names_the_daemon_user() { + let header = authorization("abc"); + let encoded = header.strip_prefix("Basic ").unwrap(); + let decoded = base64::engine::general_purpose::STANDARD.decode(encoded).unwrap(); + assert_eq!(decoded, b"redrob:abc"); + } + + #[test] + fn the_openai_base_is_v1_under_the_daemon_url() { + assert_eq!(openai_base(&record()), "http://127.0.0.1:4096/v1"); + assert_eq!(openai_base(&Record { url: "http://127.0.0.1:4096///".into(), ..record() }), "http://127.0.0.1:4096/v1"); + } + + #[test] + fn the_last_json_line_of_service_start_is_the_record() { + let out = format!("some warning\n{}\n", serde_json::to_string(&record()).unwrap()); + assert_eq!(parse_record_output(&out).unwrap(), record()); + assert!(parse_record_output("nothing here").is_err()); + } + + #[test] + fn clients_parse_with_the_optional_fields_absent_or_present() { + let c: Clients = serde_json::from_str(r#"{"clients":[],"ttlMs":90000,"idleForMs":12}"#).unwrap(); + assert_eq!(c.idle_for_ms, Some(12)); + assert_eq!(c.idle_exit_after_ms, None); + let c: Clients = + serde_json::from_str(r#"{"clients":[{"id":"a","app":"design","attachedAt":1,"lastSeen":2}],"ttlMs":90000,"idleExitAfterMs":600000}"#) + .unwrap(); + assert_eq!(c.clients[0].app, "design"); + assert_eq!(c.clients[0].pid, None); + assert_eq!(c.idle_exit_after_ms, Some(600_000)); + } + + #[test] + fn a_daemon_that_does_not_answer_is_not_healthy() { + assert!(!healthy(&Record { url: "http://127.0.0.1:9".into(), ..record() })); + } + + #[test] + fn app_headers_carry_auth_app_and_an_optional_session() { + let h = app_headers(&record(), "shape", None); + assert_eq!(h.len(), 2); + assert_eq!(h[0].0, "Authorization"); + assert_eq!(h[0].1, authorization("s3cret")); + assert_eq!(h[1], (APP_HEADER, "shape".to_string())); + let h = app_headers(&record(), "shape", Some(" doc-42 ")); + assert_eq!(h[2], (SESSION_HEADER, "doc-42".to_string())); + assert_eq!(app_headers(&record(), "shape", Some(" ")).len(), 2, "a blank session sends nothing"); + } + + #[test] + fn unavailable_tells_not_installed_from_a_start_failure() { + assert!(Unavailable::NotInstalled.to_string().contains("not installed")); + assert!(Unavailable::StartFailed("boom".into()).to_string().ends_with("boom")); + assert_ne!(Unavailable::NotInstalled, Unavailable::StartFailed(String::new())); + } +} diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index 1e676fa2be..ab976b6225 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -12,7 +12,7 @@ somewhere people are cloning from. |---|---| | `develop` | **Default.** Integration. Every feature, fix and upstream sync lands here first. | | `main` | The released state. Only ever advanced by a release pull request from `develop`. | -| `feature/` | One change, opened as a pull request into `develop`. | +| `feat/` | One change, opened as a pull request into `develop`. Also `fix/`, `docs/`, `chore/` and the rest of the organization type list, when they describe the change better. | | `sync/upstream-` | An upstream absorption, opened as a pull request into `develop`. | | `hotfix/` | A fix for something already released, into `main` **and** `develop`. | | `release/` | Only when a release needs stabilising before it ships. Usually skipped. | @@ -28,6 +28,11 @@ should not read as the same thing, because they are not. `release/*` is in the table for completeness and is usually a waste of a branch. Reach for it only when a release genuinely needs to stabilise while `develop` keeps moving. +The full type list is the redrob-labs organization's, checked by the shared gitflow workflow on +every pull request: `feat fix chore docs test refactor perf ci build sync hotfix release`. The +caller in `.github/workflows/gitflow.yml` is a verbatim copy of the organization template and +carries no list of its own. + ## Syncing upstream This is the part gitflow has no opinion about, and the part that matters most here. @@ -87,19 +92,44 @@ sorts below the release it precedes. See `docs/VERSIONING.md`. Run the `release` workflow from the Actions tab against `develop` with a `bump` of `patch`, `minor` or `major`. It computes the next version from the newest `v*` tag, builds every -platform, signs and notarizes the macOS binaries, signs the Windows binary when Azure -Trusted Signing is configured, attaches build provenance, and creates the tagged release. -It then prints a link to open the promotion pull request from `develop` into `main`. +platform, signs and notarizes the macOS binaries, signs the Windows binaries and installer +through Azure Artifact Signing, attaches build provenance, pushes the annotated tag, and +publishes the release. It then prints a link to open the promotion pull request from +`develop` into `main`. `main` moves through a reviewed pull request rather than an automated push. It is what people believe is released, and it is the one branch where an unreviewed push would be hard to notice. -Pass `dry_run: true` to build, sign and notarize while publishing nothing. That is the way -to check the signing credentials are actually visible to this repository — organization -secrets are not automatically visible to a new public repository, and an absent Azure -configuration degrades to an unsigned Windows binary with a warning rather than failing -the release. +Pass `dry_run: true` to build, sign and notarize while tagging and publishing nothing. That +is the way to check the signing credentials are actually visible to this repository -- +organization secrets are not automatically visible to a new public repository. + +The last two jobs are the organization's, not this repository's. Windows signing is the +shared composite action `redrob-labs/.github/actions/sign-windows`, and the release is the +shared reusable workflow `redrob-labs/.github/.github/workflows/release-publish.yml`, which +collects every `release-*` artifact, writes `SHA256SUMS.txt` and `redrob-launcher.json`, +and publishes `v` as latest (as a prerelease when the version carries a `-`). This +workflow passes it the tag and the commit explicitly, because the tag is created by the +`tag` job here rather than by a push. The release notes are GitHub's generated notes; the +upstream version the build is based on is in the tag message and in `UPSTREAM_VERSION`. +What stays this repository's: version resolution, the Bun build, macOS notarization, the +Linux archives, the Redrob Launcher asset copies (`launcher-assets.ts`), the `VERSION` +marker `install.sh` reads, and build provenance. + +Windows signing is Azure Artifact Signing authenticated with the job's OpenID Connect +token: the `sign-windows` job runs in the `release` environment with `id-token: write`, +and `azure/login` exchanges the token for an Azure credential right before the shared +action runs signtool with the Artifact Signing dlib. The login comes after the build and +immediately before each signing step because the OIDC token lives minutes. The inputs are +the redrob-labs organization secrets `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, +`AZURE_SUBSCRIPTION_ID` (plain IDs for the login) and `ARTIFACT_SIGNING_ENDPOINT`, +`ARTIFACT_SIGNING_ACCOUNT`, `ARTIFACT_SIGNING_CERT_PROFILE` (the account to sign with); no +certificate file and no client secret exist anywhere. The three `ARTIFACT_SIGNING_*` names +alone decide whether signing happens -- all unset means an unsigned build with a warning, +some set is an error. Every signed file is read back with `signtool verify /pa` and must be +`Valid` with the signer `CN=McKinley Rice, Inc.`; the log prints ` signed by CN=...`. +A failed signature fails the job and so the release. ## Hotfixes @@ -107,10 +137,11 @@ Branch from `main`, not `develop`, so the fix does not drag unreleased work with Open the pull request into `main`, release from there, then merge `main` back into `develop` so the fix is not lost on the next release. -That last step is checked, because it is the one that gets skipped. `branch-sync.yml` runs -on every push to `main` and fails while `main` holds a commit `develop` does not, listing -them. A promotion pull request from `develop` leaves the two identical and the check says -nothing; a hotfix reports until it is brought back. +That last step is checked, because it is the one that gets skipped. The shared gitflow +workflow (`gitflow / main is contained in develop`) runs on every push to `main` and, while +`main` holds a commit `develop` does not, opens the back-merge pull request itself as +`sync/back-merge-`. A promotion pull request from `develop` leaves the two identical +and the check says nothing; a hotfix opens the back-merge until it is brought back. The invariant it enforces, which you can read yourself at any time: diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md new file mode 100644 index 0000000000..7a58cca0a3 --- /dev/null +++ b/docs/LOCAL-ENGINE-API.md @@ -0,0 +1,264 @@ +# The local engine API + +Every Redrob product needs the same three things from this engine: a credential the +user only supplies once, model and provider routing, and — the reason this document +exists — the option of inference that does not leave the machine. None of those is +the agent. So the shared surface is a plain inference endpoint, and the agent stays +where it already is. + +This document is the design of record for that endpoint, for promoting the +`/experimental` surface, and for shipping the engine as one shared install instead +of a copy inside every product. + +## Why this is being written now + +Three products were already written against a local engine endpoint that has never +existed. + +- `redrob-browser` POSTs to `/experimental/external/chat/completions` and + streams the SSE reply (`sources/chrome/browser/redrob/sidecar_client.cc:592`). No + such route is served: the experimental group registers `capabilities`, `console`, + `tool`, `worktree`, `session` and `resource` and nothing else + (`packages/redrob/src/server/routes/instance/httpapi/groups/experimental.ts:91-101`). + The string `external` does not appear anywhere under `packages/redrob/src/server`. +- `redrob-shape` ranks a LOCAL transport at `http://127.0.0.1:4096` and health-probes + it, but its sidecar rejects any non-HTTPS base URL, so that transport cannot carry a + request even when the probe succeeds. +- `redrob-office` compiles this engine from source at release time and ships it inside + its installer, with a packaging guard that refuses to build without it — and never + calls it. + +The products that DO drive the engine successfully (`redrob-cowork`, `redrob-cad`) +drive its own session API. The ones that tried to give the engine an OpenAI face are +the ones that are broken. That is the signal this design follows: serve the OpenAI +face for real, at one stable path, and stop pretending the session API is one. + +## 1. `POST /v1/chat/completions` + +The core path. There is no alias: `/experimental/external/chat/completions` is not +served, and `redrob-browser`'s call site moves to `/v1/chat/completions` in the same +wave (see Migration). + +### Where it sits + +On `packages/llm` and the provider registry — NOT on `Session`. A chat completion is +stateless and a session is not; mapping one onto the other is what forces an +ephemeral-session-per-request design and a fake OpenAI face over an event bus. The +route resolves the provider and credential exactly as the agent does, then makes the +model call directly. + +This is also what makes local inference work: a locally served model is just another +provider the registry resolves, and callers see no difference. + +### Request + +An OpenAI chat-completions subset: + +| field | required | notes | +| ------------------ | -------- | ----------------------------------------------------------------------------------- | +| `model` | yes | `redrob/auto` routes through the Console; a local provider id targets a local model | +| `messages` | yes | `system` / `user` / `assistant` / `tool` roles | +| `tools` | no | **presence changes who executes them — see below** | +| `tool_choice` | no | `auto` \| `none` \| a named function | +| `stream` | no | default `false`; `true` returns SSE | +| `max_tokens` | no | | +| `temperature` | no | ignored for models that fix their own sampling | +| `reasoning_effort` | no | passed through where the provider accepts it | + +Unknown fields are ignored rather than rejected, so a newer client can talk to an +older engine without a version check. + +### Tool ownership is decided by the request + +This is the whole design. One rule, no modes to configure: + +- **`tools` present → the CALLER owns them.** The engine does not execute anything. + It emits `tool_calls` and ends the turn with `finish_reason: "tool_calls"`. The + caller executes them and sends a follow-up request carrying `role: "tool"` + messages. Standard OpenAI semantics, and stateless. +- **`tools` absent → the ENGINE owns them.** Present behaviour: the engine selects + and runs its own tools and returns final text. + +In v1 the two do not mix. Declaring `tools` disables engine-side tools. One +predictable rule beats three configurable ones, and it is the rule every caller +already assumes. + +Why it has to be this way: `redrob-office`'s tools edit an open document held in the +renderer. `redrob-browser`'s tools drive a live tab. Neither can be executed by the +engine — handed over, the engine would edit the disk instead of the document. Caller +ownership is not a preference, it is the only correct answer for those hosts. + +### Streaming + +`stream: true` returns `text/event-stream`: OpenAI delta chunks, then +`data: [DONE]`. Tool calls stream as `delta.tool_calls` fragments keyed by index, the +way callers already parse them. + +### Auth + +Inbound uses the server's existing HTTP Basic (`packages/redrob/src/server/auth.ts:18`). +Callers do NOT send a Console key. + +Outbound uses the engine's OWN Console credential — `REDROB_API_KEY`, then the +credential store, then `auth.json` (`packages/core/src/console-key.ts:34-72`). This is +the single-login property: one `redrob providers login` and every product is +authenticated, because no product holds a key of its own on this path. + +### Errors + +The OpenAI error envelope, so existing client error handling works unchanged: + +```json +{ "error": { "message": "...", "type": "...", "code": "..." } } +``` + +One code is load-bearing: when the engine itself has no Console credential, the reply +is `401` with `code: "engine_not_authenticated"`. That is the only condition under +which a client should offer a sign-in action. Clients must branch on this code and not +on the message text, which is localized. A client that offers sign-in on any failure +reports a network timeout as "please log in" — that shipped, and it is what this code +exists to prevent. + +## 2. Promoting `/experimental` + +Every `/experimental/*` route is promoted out of the experimental namespace and +becomes part of the stable surface. + +This is safe to do as one breaking rename, because **no product calls any of them.** +The only callers are this repo's own generated SDKs +(`packages/sdk/js/src/gen`, `packages/sdk/js/src/v2/gen`), which `httpapi-codegen` +regenerates from the route definitions. The one external reference to an experimental +path is `redrob-browser`'s dead completions call, which this design replaces anyway. + +So: rename the paths, regenerate the SDKs, and delete the experimental namespace. No +compatibility window is owed to callers that do not exist. + +## 3. One shared install, no bundling, no pin + +Products stop shipping a copy of this binary. `redrob-office` drops ~145 MB from its +installer and drops its packaging guard; `redrob-cowork` drops its download-and-stage +step. + +### Resolution order + +1. `REDROB_CODE_BIN` — explicit override, development +2. `$HOME/.redrob/bin/redrob` — where `install.sh` already installs +3. `PATH` +4. absent → the product offers a one-click install that runs the official installer + (GitHub Releases, verified against `SHA256SUMS.txt`) + +### One daemon, not one per product + +Built. `redrob serve --register` is the daemon; `redrob service start | status | stop | restart` +drives it by hand, and `redrob service start --json` prints the record for a caller in another +language (the launcher is Rust). The record is `$HOME/.redrob/run/server.json` +(`REDROB_RUN_DIR` overrides the folder), mode `0600`: + +```json +{ "id": "…", "version": "0.5.0", "url": "http://127.0.0.1:4096", "port": 4096, "pid": 8412, "secret": "…" } +``` + +`id` is random per start, so a client that saw another id knows the daemon restarted. `secret` +is the Basic password (username `redrob`), drawn fresh on every start: a stale record never opens +a running server, and an app that loses its connection re-reads the file rather than keeping a +secret of its own. + +The first app that needs the engine starts it; the rest attach. `connectOrStart` in +`@redrob-labs/sdk/daemon` (JavaScript) and `packages/redrob/src/daemon/connect.ts` (the CLI) do +the required steps: a liveness check before attaching (`/api/health` with the record's secret), a +stale record whose pid is gone is removed, a healthy daemon of another version is stopped and +replaced (an upgrade), and the new record is awaited. + +Attached apps are counted. `POST /api/clients {app, pid}` attaches, `POST +/api/clients/:id/heartbeat` keeps it (expected within `ttlMs`, 90 s; the SDK's `attach()` does this +for you), `DELETE /api/clients/:id` detaches, `GET /api/clients` lists them with `idleForMs` and +the daemon's `idleExitAfterMs`. A registered daemon exits once nobody has needed it for +`--idle-exit` minutes (default 10; 0 keeps it up): no app attached, no request in flight, and no +authorized request since -- so an app that only calls the API (the Rust apps: Shape, Canvas, Query, +Recall, through `redrob-engine-client`) keeps it alive by using it, and it never leaves under a +request still being answered. Status polls (`/api/health`, `/api/clients`) do not count: the +launcher's engine card asks every few seconds. It also exits when another daemon has taken over +the record. `redrob acp --attach` makes the ACP face a thin client of the daemon instead of an +engine of its own, so ACP hosts (Design, Cut, CAD, Browser) share the one process too. + +Per-product processes were considered and rejected: with local inference, N processes +means the same weights loaded N times, which defeats the reason for doing this. + +One daemon also means an app can no longer hand "its" engine a private config through the +environment (`REDROB_CONFIG`, `REDROB_SERVER_URL`, …): env is per process, and the process is +shared. What an app needs to tell the engine about one directory goes in a file the engine reads +for that directory: `/directories//redrob.json` (`` is `$XDG_STATE_HOME/redrob`, +else `~/.local/state/redrob`; `` is the SHA-256 of the absolute, normalised directory path, +lower-cased on Windows). `directoryConfigDir(dir)` in `@redrob-labs/sdk/daemon` and +`ConfigPaths.directoryConfigDir` in the engine compute it. It merges after the global config and +before the project's own `redrob.json`, exactly where `REDROB_CONFIG` merged, so the user's files +still win; it lives outside the project tree so it never collides with them or shows up in git. +After writing it the app calls `POST /instance/dispose` for the directory and the next request +rebuilds the instance on the new file. Anything a plugin used to read from the spawning app's env +belongs in this file too (for example under a key the plugin owns), because the daemon's env is +nobody's. + +The same goes for *which app is calling*. The agent runner labels its provider requests with +`x-redrob-app` from `REDROB_APP`, which only names the app that started a private engine. On the +shared daemon `POST /v1/chat/completions` reads the labels from the request instead: send +`x-redrob-app: ` and `x-redrob-session: ` and the engine forwards them to +the provider as `x-redrob-app` and `X-Session-Id` (plus `x-session-affinity`), the names the +runner uses, so the Console groups both paths alike. Absent headers forward nothing; no other +request header crosses. + +Detecting the engine from another language, without the SDK: `redrob service status --json` +prints the record as one JSON line and exits `0` when a healthy daemon is registered, prints +`null` and exits `1` when none is. `redrob service start --json` prints the record (starting the +daemon when needed) and exits `0`, or exits non-zero with the reason on stderr. A missing `redrob` +binary (not on `PATH`, no `REDROB_BIN`) is the "engine not installed" case: the app should say so +and point at the launcher or the Code install, never fall back to calling a provider directly -- +two paths would mean the user cannot tell whether masking applied. The Rust crate +`redrob-engine-client` (`crates/engine-client` in this repository, pinned by apps as a git +dependency on the engine tag they target) wraps all of this for Shape, Canvas, Query, Recall and +the launcher. + +That file is the auth boundary — it carries the Basic secret. It is per-user by +location and must stay `0600`; a multi-user machine gets one daemon per user, never a +shared one. + +### Capability negotiation replaces pinning + +No product pins an engine version. Auto-update would otherwise break clients +silently. Instead `/capabilities` (promoted, formerly `/experimental/capabilities`, +handler at `packages/redrob/src/server/routes/instance/httpapi/handlers/experimental.ts:39-41`) +grows an entry: + +```json +{ "chatCompletions": { "version": 1, "callerTools": true, "stream": true }, "sharedDaemon": { "version": 1 } } +``` + +A product declares the MINIMUM capability it needs, never a version number. If the +entry is missing or its `version` is below that minimum, the product tells the user to +run `redrob upgrade` instead of failing with a transport error. + +### Backward compatibility, as rules rather than a promise + +- The contract is versioned by PATH (`/v1/...`), never by engine version. +- Adding a request or response field is allowed. Removing one, or changing what one + means, requires `/v2`. +- When `/v2` appears, `/v1` keeps being served for two minor versions. +- A contract test in this repo pins the v1 request and response schema and fails CI + when a field is removed or its type changes. Without that test "we keep + compatibility" is a sentence, not a guarantee. + +## 4. Migration + +| product | change | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| redrob-browser | point `sidecar_client.cc:592` at `/v1/chat/completions`; its host-owned tool loop already matches the contract | +| redrob-office | drop the bundled binary and the packaging guard; point the engine route at the local daemon; keep its editor-owned tool loop | +| redrob-cowork | drop the download-and-stage step and the duplicate alias copy; keep driving the session API for agent work | +| redrob-shape | either pass the local base URL and allow loopback HTTP in its sidecar, or delete the LOCAL transport that cannot work | +| redrob-canvas, redrob-query, redrob-recall | optional: switching from the Console to the local daemon buys them one shared login | +| redrob-extension, redrob-cad | unchanged; they want engine-owned tools and the session API already serves that | + +## Open items + +- Whether `tools` and engine-owned tools may ever be combined in one turn. Deferred + out of v1 deliberately. +- Which local inference providers the registry should resolve first. diff --git a/docs/LOCAL-HARNESS-BACKENDS.md b/docs/LOCAL-HARNESS-BACKENDS.md new file mode 100644 index 0000000000..a7a4fd7b89 --- /dev/null +++ b/docs/LOCAL-HARNESS-BACKENDS.md @@ -0,0 +1,261 @@ +# Local harness backends, for every product at once + +How Codex and Claude Code become an option in *all* Redrob products — office, +cowork, browser, design, extension, canvas, query, recall, cad, shape — from one +implementation in this engine, rather than ten adapters in ten repositories. + +Companion documents: `docs/PROVIDER-AUTH.md` (what each vendor permits, and why we +never hold a token) and `docs/LOCAL-ENGINE-API.md` (the `/v1/chat/completions` +contract this rides on). redrob-cowork `docs/CODEX-RUNTIME.md` holds the working +prototype that proved the subprocess mechanics. + +## The mistake this corrects + +The first adapter was written inside redrob-cowork, behind that app's engine-spawn +hook. It works, and it found real bugs, but its home is wrong: it gives Cowork a +Codex option and gives the other nine products nothing. Ten products would mean +ten adapters, ten settings screens, ten credential stories, and ten places for the +subprocess environment bug described below to be re-introduced. + +The adapter belongs here instead, because every product already reaches a model +through this engine — and the ones that still call the Console directly are exactly +the ones `/v1/chat/completions` was built to bring in. + +## The mechanism already exists + +`packages/core/src/config/plugin/local-provider.ts` admits a provider when two +conditions hold: the package is exactly `@ai-sdk/openai-compatible` (the trusted, +pinned one the Console provider itself uses), and the URL is on this machine or +network. That door was opened for Ollama, LM Studio, llama.cpp and vLLM, whose only +shared trait is that they **speak OpenAI-compatible chat-completions over a local +address**. + +A harness can meet the same bar. Put a small local server in front of `codex exec` +or `claude -p` that speaks chat-completions, and it is admissible through a path +this engine already trusts — no new provider-loading machinery, no widening of the +arbitrary-npm-package refusal, no second credential store. + +``` +codex exec / claude -p subprocess; holds its OWN credential + ▲ + harness shim local, OpenAI-compatible, 127.0.0.1 + ▲ + redrob-code engine registered via the existing local-provider path + ▲ + POST /v1/chat/completions one receiving route + ▲ + office · cowork · browser · design · extension · canvas · query · recall · cad · shape +``` + +Models then select a backend by id — `codex/`, `claude-code/` — which +is the same way a local model is already selected, and needs no new request field. + +### Why a shim here, having argued against one in Cowork + +`docs/CODEX-RUNTIME.md` recommends *against* a protocol shim in Cowork and this +document recommends one. That is not an inconsistency, it is the surface being +different, and the difference is the whole argument. + +A Cowork shim would have to emulate the **OpenCode session API** — sessions, the SSE +event shape, permissions — which is large, ours, and still changing. A shim that +falls subtly behind produces bugs that look like model bugs. A shim here emulates +**chat-completions**, which is small, published, frozen, and not ours to change. The +first is a maintenance liability; the second is an adapter against a stable +contract. + +## Two tiers, and most products only need the first + +This is the part that cannot be papered over: **Codex and Claude Code are agent +harnesses, not completion endpoints.** They run their own loop with their own +tools. So there are two levels of support, not one. + +**Chat tier — all ten products.** Prompt in, text out. The harness's loop runs but +its tools are constrained to nothing the caller did not ask for. This covers every +product's ordinary AI use: rewrite this paragraph, summarise this sheet, answer +this question. It maps cleanly onto `/v1/chat/completions` and needs no per-product +code. + +**Agent tier — cowork, code, cad.** The harness runs a real agent loop against a +workspace. chat-completions cannot express this, because that contract's rule is +"tools present → the caller owns them", and here the *runtime* owns the loop. This +needs ACP or a session surface, and it is separate work. Do not let it block the +chat tier. + +A safety point that belongs in the chat tier and is easy to miss: a harness given a +writable sandbox will happily read and edit the user's filesystem. A user asking +Office to reword a sentence has not consented to an agent walking their disk. So +the shim pins `--sandbox read-only` with approvals set to never, and network access +off, unless a caller is on the agent tier and asked for more. The prototype already +defaults this way; the shim must not relax it for convenience. + +The tool-ownership split surveyed earlier decides which products need more than the +chat tier: + +- **No tool protocol** — query, recall, shape. Chat tier is the whole story. +- **Host-owned tools** — office, browser, canvas. Chat tier works today. Giving the + harness their tools means a **stdio MCP server** (`~/.codex/config.toml`, a + project-scoped `.codex/config.toml`, or per-invocation + `--config mcp_servers.…`; Claude Code takes `--mcp-config` plus + `--strict-mcp-config`). Codex app-server's `dynamicTools` would let the tool stay + inside the host process instead, and an earlier draft of this document called that + "the better fit" — that recommendation is **withdrawn** on evidence: + + - OpenAI's stability warning got *broader*, not narrower. The + [2026-06-24 snapshot](http://web.archive.org/web/20260624142043/https://developers.openai.com/codex/app-server) + carried no production warning at all and scoped "experimental" to the WebSocket + transport; today's page says "The app-server command and WebSocket transport are + experimental and aren't supported for production workloads." + - app-server has **no row at all** in OpenAI's + [Feature Maturity](https://developers.openai.com/codex/feature-maturity) table. + - `dynamicTools` is double-experimental: gated behind + `capabilities.experimentalApi`, and **absent from the generated + `ThreadStartParams` bindings**, so the field you must send is one you hand-write + against no type. It has already changed wire shape once — `LegacyDynamicToolSpec` + exists as the compat scar, with `exposeToContext` replaced by the *inverted* + `deferLoading`. + - No protocol version, no breaking-change log, and an open regression in the `-c` + MCP path under app-server mode + ([openai/codex#39537](https://github.com/openai/codex/issues/39537)). + + The counter-signal is real and is why this is "revisit in a quarter" rather than + "avoid": Zed's ACP adapter *moved onto* app-server + ([agentclientprotocol/codex-acp](https://github.com/agentclientprotocol/codex-acp)), + and OpenAI's own IDE surfaces run on it. The one thing MCP cannot give us is a tool + whose implementation lives in our own process memory. When Office genuinely needs + that, budget for re-porting `dynamicTools` at least once. +- **Engine-owned tools** — cowork, cad, extension, design. Under a harness backend + the engine's own tools are not in the loop at all. That is the agent tier's + problem to solve. + +## The vendor risk is asymmetric, and it sets the order + +An earlier draft of this document said driving the unmodified binary is "the +documented exception". That was too confident, and the correction matters enough to +change sequencing. + +Anthropic began blocking third-party harnesses from Claude **subscription** billing +on **2026-04-04**, and the restriction is described as being extended to *all* +third-party harnesses +([claude-mem#1826](https://github.com/thedotmack/claude-mem/issues/1826), which links +the press coverage). OpenClaw is researching a `--method cli` path precisely because +direct OAuth token use has been blocked since then — so CLI-driving is the surviving +workaround, **not a safe harbour**. The same issue warns that spawning a `claude` +subprocess through the Agent SDK "is exactly the 'third-party harness' pattern +Anthropic is restricting". Anthropic's own June support article still says +subscription limits fund `claude -p` and third-party apps and that the metering +change is paused; treat the two together as a trajectory, not a guarantee. + +OpenAI points the other way: it publishes the integration surface as a platform, and +LiteLLM openly ships a `chatgpt/` provider for subscription access. + +Two consequences, both load-bearing: + +1. **BYOK with the user's own API key is the first deliverable, not the fallback.** + It is unaffected by any of the above, it is the path Anthropic and Google both + name as the supported one for a third-party tool, and Office's policy is already + open for it. +2. **Codex before Claude Code**, for business reasons rather than technical ones. + +## Nothing off the shelf is reusable + +Surveyed 2026-09-22. No existing project is production-credible for our requirement +— drive the local CLI *and* pass caller-supplied tools through: + +- [claude-code-api-rs](https://github.com/ZhangHanDong/claude-code-api-rs) (177★, + MIT) is the only mature native `tools` → `tool_calls` mapping, and it is Rust, + five months without a commit, and ships with + `use_interactive_sessions = false # Disabled by default due to stability issues`. +- [claude-code-openai-wrapper](https://github.com/RichardAtCT/claude-code-openai-wrapper) + (622★) states in its own README that function calling is not supported. +- [codingworkflow/claude-code-api](https://github.com/codingworkflow/claude-code-api) + (331★) is **GPL-3.0** — viral, so unusable in a shipped product. +- [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) (52.8k★) looks like the + obvious answer and is not: it never spawns the CLI. It performs the CLI's OAuth + flow itself, stores the token, and calls the vendor backend — the exact + architecture Anthropic blocked in April. +- LiteLLM, OpenRouter, Portkey, Helicone: no provider that drives a local Claude Code + or Codex CLI. + +Two specifics are worth copying rather than the repos: the **pooled long-lived CLI +process** model and the native tools mapping from claude-code-api-rs, and the trick of +launching with `--tools "" --setting-sources "" --system-prompt `, which +strips roughly 28k tokens of the harness's own agent prompt and built-in tools out of +every request. That second one is the concrete mechanism behind the chat tier — it is +what turns an agent harness into something that behaves like a completion endpoint. + +## What the products have to do + +Almost nothing, which is the point. + +Nothing at all to *work*: a product that names a model and calls the engine gets +the new backends when the engine gets them. + +One thing to be *usable*: somewhere to turn it on, and three states rather than +one — runtime not installed, installed but not signed in, ready. Collapsing those +into "unavailable" strands the user, because the remedy differs and we are not +allowed to offer the vendor's login ourselves. The remedy we may show is "run +`codex login`" or "run `claude`". + +Both states are readable by ASKING the runtime, never by reading its credential +store: `claude auth status` exits 0 when signed in and 1 when not (its JSON field +names are undocumented, so the exit code is the contract), and Codex app-server +exposes `account/read`. A `claude -p` run also reports `apiKeySource` in its +`system`/`init` event. + +Credentials need no work anywhere. The harness holds its own; the engine holds none +for it. Cowork already demonstrates the pattern for the BYOK case — it stores no +provider key and treats the engine's `auth.json` as the single source of truth. + +## Sequencing + +1. **BYOK with the user's own API key.** Moved to the front — see the vendor-risk + section. The engine side is **already built** — `GET /api/integration` returns + each integration's `methods` (OAuth / Key / Env) and its `connections`, and + `connect/key`, `connect/oauth`, the attempt routes and `DELETE /api/credential` + complete the set. An earlier draft of `docs/PROVIDER-AUTH.md` proposed a parallel + `/v1/providers` surface; that is withdrawn, because it would duplicate these over + the same store. The remaining work is product-side: make an app use them instead + of its own key store. Cowork already does (`apps/server/src/redrob-auth.ts`). +2. **Use the user's OWN binary.** The Codex SDK pins `@openai/codex` exactly and + resolves the executable from its own bundled platform packages unless + `codexPathOverride` is passed — so the default behaviour runs a SECOND copy we + downloaded, not the one the user signed in to. That re-creates the duplicate-engine + problem this whole effort exists to remove. Detect the user's install and pass the + path explicitly. Claude Code is proprietary with no redistribution grant, so it is + the only option there anyway, which makes both runtimes the same shape. +3. **Build the shim with both backends.** One local chat-completions server; two + normalizers behind it. The prototype's split — event folding separated from the + subprocess — is what makes the second runtime a second normalizer rather than a + second architecture. Reuse it rather than re-deriving it. +4. **Give the harness our tools through a stdio MCP server.** Not app-server. Know + the defaults before wiring: Codex's `startup_timeout_sec` is 10, `tool_timeout_sec` + is 60, and `required = true` makes `codex exec` exit with an error rather than + silently running without our server. +5. **Register through the local-provider path**, and report the harness backends + through `GET /api/integration` alongside every other integration, with their + three states, so a product renders settings from one list rather than hardcoding + a vendor set that goes stale. +6. **Verify against real binaries.** Neither runtime is installed on the build + host, so the live path — a real subscription actually paying for a turn — is + unproven until someone runs it on a machine with `codex` and `claude` signed in. +7. **Then the agent tier**, for cowork, code and cad, over ACP. + +One thing to carry forward rather than discover later: Anthropic has announced, then +paused, a change moving third-party subscription usage onto a capped monthly credit, +so the Claude backend should surface usage state rather than assume it is free. Note +also that `claude -p`'s credential precedence puts the OAuth login **last** — +`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `apiKeyHelper`, `CLAUDE_CODE_OAUTH_TOKEN` +and `ANTHROPIC_PROFILE` in the environment all silently take over billing, and +`--bare` never reads the OAuth login at all. That is the same class of bug as the +Codex env trap, in the opposite direction: the shim must build the child environment +from an allow-list for both runtimes. + +`claude -p`'s `stream-json` schema IS now mapped, so step 3 does not start by +guessing: `system`/`init` carries `session_id`, `tools`, `mcp_servers` and +`apiKeySource`; assistant text and `tool_use` blocks are in +`assistant.message.content`; and the final `result` message carries +`subtype` (`success` or `error_*`), `is_error`, `total_cost_usd` and `modelUsage`. +Read cost from `modelUsage`, not `usage` — `usage` covers the main loop only and +undercounts subagents. Pin non-interactive behaviour with +`--permission-mode dontAsk --permission-prompts none`, and read-only with +`--tools "Read,Glob,Grep" --disallowedTools "Edit" "Write" "NotebookEdit" "Bash" "mcp__*"`. diff --git a/docs/PROVIDER-AUTH.md b/docs/PROVIDER-AUTH.md new file mode 100644 index 0000000000..a0429839b2 --- /dev/null +++ b/docs/PROVIDER-AUTH.md @@ -0,0 +1,202 @@ +# Provider authentication + +How a user connects their own model access to this engine, and therefore to every +product built on it. One credential store, read by all of them, so a user sets up +once rather than once per app. + +The goal this answers is "let people use the Claude or ChatGPT access they already +pay for". Part of that is available and part of it is not, and the split is not +technical — it is what each vendor's terms permit a third-party application to do. +So the shapes are enumerated first, then the design. + +Not legal advice. Every claim below links the document it came from, checked +2026-09-22; re-check before shipping, because three of these pages changed in the +first half of this year. + +## What each vendor actually allows a third-party app + +| vendor | "sign in" for a third-party app | user's own API key | user's consumer subscription | +| --- | --- | --- | --- | +| Anthropic | **No** — expressly forbidden | Yes, expressly | **No** — prohibited, enforced with account bans | +| OpenAI | No self-serve registration; granted case by case | Yes | Only by embedding OpenAI's own Codex runtime | +| Google Gemini | OAuth exists but bills *your* Cloud project | Yes — the named supported path | **No** — prohibited, enforced with bans | +| GitHub Copilot | **Yes** — the Copilot SDK, officially | Yes | Yes, billed to the user's own subscription | +| OpenRouter | **Yes** — OAuth PKCE | Yes | n/a (it is BYOK by design) | +| Azure OpenAI | **Yes** — Microsoft Entra ID | Yes | n/a (user's own Azure resource) | +| Amazon Bedrock | No (SigV4 / Bedrock API keys) | Yes — the user's own AWS account | n/a | +| AWS Kiro | **No** | Kiro API key, subscriber-only | Only by driving the user's own installed CLI | + +Sources, and the sentences that decide it: + +- Anthropic, [Claude Code legal and compliance](https://code.claude.com/docs/en/legal-and-compliance): + "Anthropic does not permit third-party developers to offer Claude.ai login into + their own applications, or to route requests through Free, Pro, or Max plan + credentials on behalf of their users." And developers "should use API key + authentication through Claude Console or a supported cloud provider." Enforced: + accounts were banned for spoofing the Claude Code harness, and our own upstream + (OpenCode) removed Claude subscription AND Claude API key support on 2026-02-19 + citing Anthropic legal requests ([The Register, + 2026-02-20](https://www.theregister.com/software/2026/02/20/anthropic-clarifies-ban-on-third-party-tool-access-to-claude/5014546)). + The only sanctioned subscription route is shipping Claude Code itself, + unmodified, with its auth methods intact. +- OpenAI, [Codex authentication](https://developers.openai.com/codex/auth/): + "Sign in with ChatGPT" is documented for OpenAI's own surfaces only. Third + parties that have it — Zed, OpenClaw — get there by wrapping OpenAI's own Codex + runtime ([Zed](https://zed.dev/blog/chatgpt-subscription-in-zed)). Treat that as + a revocable product decision, not an entitlement: no terms clause grants it. +- Google, [Gemini CLI FAQ](https://github.com/google-gemini/gemini-cli/blob/main/docs/resources/faq.md): + "the supported and secure method is to use a Vertex AI or Google AI Studio API + key", and piggybacking Gemini CLI's OAuth "may be grounds for immediate + suspension or termination". Enforced in + [this thread](https://github.com/google-gemini/gemini-cli/discussions/20632). + Note the Gemini API terms also say the API is "not for consumer use" and + require paid services for EEA/UK/CH users. +- GitHub, [Copilot SDK OAuth setup](https://docs.github.com/en/copilot/how-tos/copilot-sdk/setup/github-oauth): + "Copilot requests are made on behalf of each authenticated user, using their + Copilot subscription… Your app never handles model API keys." A device flow is + documented for exactly our case, "Desktop applications where users interact + directly". +- OpenRouter, [OAuth PKCE](https://openrouter.ai/docs/guides/overview/auth/oauth): + send the user to `/auth` with a `code_challenge`, exchange the code for a + **user-controlled API key**. Loopback callback on any port, plus a headless + paste mode. +- Azure, [Entra ID auth](https://learn.microsoft.com/azure/ai-services/openai/how-to/managed-identity) + with the [device authorization grant](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-device-code). +- Kiro, [Authentication](https://kiro.dev/docs/getting-started/authentication/): + subscriber `ksk_` API keys exist; the only documented embed path is driving the + user's own CLI over [ACP](https://kiro.dev/docs/cli/acp/). + +### What this means for the product ask + +"Sign in with Claude" and "Sign in with ChatGPT", as buttons in our own apps +spending the user's consumer subscription, are not available. Building them means +impersonating a first-party client, and the vendors ban accounts for it — the cost +lands on our users, not on us. + +What IS available, and covers most of the intent: + +1. **BYOK for every provider.** Anthropic and Google both name this as the + supported path for third-party tools. A user with a Claude API key or a Gemini + key connects in one step. +2. **Three real sign-in buttons**: GitHub Copilot, OpenRouter, Azure OpenAI. The + first two are the interesting ones — Copilot spends the user's own Copilot + subscription with GitHub's blessing, and OpenRouter fronts Claude and GPT + models behind an account login, which is the closest legitimate thing to what + was asked for. +3. **Optionally, unmodified first-party binaries.** Shipping Claude Code or the + Codex CLI as-is and letting it authenticate itself is sanctioned by both + vendors. It is a different product shape — their harness, not ours — so it is + recorded here as available rather than recommended. + +## Design + +### One store, in the engine + +Credentials live where they already live: `Global.Path.data/auth.json`, through +`packages/redrob/src/auth`, whose `Info` union is already `Oauth | Api | +WellKnown`. `packages/core/src/console-key.ts` reads env → credential store → +that file, and `Integration` resolves a connection into a `Credential.Value` for +the provider layer. + +Nothing new is invented, because the point is that products stop having stores of +their own. Today Office keeps keys in `userData/ai-settings.json`, Design in the +macOS keychain, the extension in `chrome.storage.local`, Query and Recall in +separate keyring services — five stores that never read each other, which is why +a user logs in again in every app. + +A product reads the engine's store instead. It keeps its own as a cache if it +wants, but the engine's file is the source of truth, and the engine is what makes +the call. + +### Products never hold a key + +`POST /v1/chat/completions` (see `docs/LOCAL-ENGINE-API.md`) already carries the +engine's credential outward and takes none from the caller. That is the whole +mechanism: a product names a `model`, the engine resolves the provider and its +credential. Adding a provider is then a change in one place, and every downstream +app gets it without shipping a release. + +This also removes a class of bug rather than moving it: a product that holds no +key cannot leak one, log one, or sync one. + +### Connecting a provider + +`redrob providers login` already implements both shapes — a generic OAuth flow +with `authorize()` plus `auto` and `code` callbacks, and an API-key path +(`packages/redrob/src/cli/cmd/providers.ts`). + +**An earlier draft of this section proposed four new `/v1/providers` routes to expose +it. That was wrong: the routes already exist.** They are the `server.integration` +group on the v2 surface, and they already carry everything a settings page needs. +Building the `/v1/providers` set would have been a duplicate surface over the same +credential store, with two code paths to keep in agreement. + +What exists, and what it replaces from that proposal: + +| proposed | already exists | +| --- | --- | +| `GET /v1/providers` | `GET /api/integration` — returns `Integration.Info`, whose `methods` array is a union of `OAuthMethod`, `KeyMethod` and `EnvMethod`, plus `connections` for what is already connected | +| `POST /v1/providers/:id/login` (key) | `POST /api/integration/:integrationID/connect/key` | +| `POST /v1/providers/:id/login` (oauth) | `POST /api/integration/:integrationID/connect/oauth` | +| `POST /v1/providers/:id/login/:attempt` | `POST /api/integration/attempt/:attemptID/complete`, with `GET /api/integration/attempt/:attemptID` for status and `DELETE` to cancel | +| `DELETE /v1/providers/:id/credential` | `DELETE /api/credential/:credentialID` | + +So the engine-side work for BYOK is **done**, and the real work is product-side: +making an app use these routes instead of its own store. Office keeps keys in +`userData/ai-settings.json`, Design in the macOS keychain, the extension in +`chrome.storage.local`, Query and Recall in separate keyring services — five stores +that never read each other, which is the whole reason a user logs in again in every +app. Cowork already does it the right way: it stores no provider key and posts the +key once to the engine (`apps/server/src/redrob-auth.ts`), treating the engine's +`auth.json` as the single source of truth. That is the pattern to copy. + +Three rules still hold for those routes, and they are worth restating because they +are what makes the single store safe: + +1. **The key never comes back out.** A response says a credential is present and + names it; it never returns the secret. A product that cannot read the key + cannot mishandle it, and this is also what keeps the loopback API from becoming + a credential-exfiltration endpoint if something else on the machine reaches it. +2. **The OAuth code stays in the engine.** The product gets the URL to open and an + opaque id, nothing else — the same split `apps/shell/src/main/redrob-connect.ts` + already uses between Electron's main process and its renderer. +3. **No caller-supplied base URL.** Keep this ban. It is not only policy: the + config layer admits a new openai-compatible provider only at a local address + (`isLocalEndpoint`), and honouring an arbitrary URL from a request would + forward the engine's own credential to whatever host the caller named. A local + model is selected by its `provider/model` id. + +### Which providers get a login button + +Ship BYOK for all of them. Add OAuth only where the vendor documents it for third +parties: GitHub Copilot, OpenRouter, Azure OpenAI. Anthropic, Google, OpenAI and +Bedrock get an API-key field and no button. + +The UI should say which is which. A user who expects "sign in with Claude" and +finds a key field deserves the reason in one line — that Anthropic requires an API +key for third-party tools — rather than being left to assume the feature is +missing. + +### Order of work + +1. `GET /v1/providers` and the API-key path. This alone gives Office, Cowork and + Design one shared BYOK setup, and needs no vendor negotiation. +2. OpenRouter OAuth. Smallest real sign-in button and the one that reaches Claude + and GPT models legitimately. +3. GitHub Copilot via the Copilot SDK. A user's existing Copilot subscription, + sanctioned, with a documented device flow for desktop. +4. Azure OpenAI via Entra device code. + +## What must not be built + +- Reusing Claude Code's or the Codex CLI's OAuth client id to spend a consumer + subscription from our own harness. Anthropic and Google both ban accounts for + it; our upstream removed the code under legal pressure. +- Collecting, storing or proxying Claude.ai session tokens. Named explicitly in + Anthropic's compliance page. +- Paying for or intermediating another vendor's usage on a user's behalf. Also + named there, and it is what "just put our key in it" would amount to. + +A compliance check worth keeping: this repository currently contains no Anthropic +OAuth client id and no `claude.ai` endpoint. The only hardcoded vendor OAuth is +xAI's (`packages/redrob/src/plugin/xai.ts`). Keep it that way. diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 2808fc86b4..d072c9e35e 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -26,6 +26,7 @@ export const groupNames = { "server.provider": "providers", "server.integration": "integrations", "server.credential": "credentials", + "server.privacy": "privacy", "server.permission": "permissions", "server.fs": "files", "server.command": "commands", @@ -33,6 +34,7 @@ export const groupNames = { "server.event": "events", "server.pty": "ptys", "server.question": "questions", + "server.browser": "browserRequests", "server.reference": "references", "server.projectCopy": "projectCopies", } as const @@ -48,6 +50,9 @@ export const endpointNames = { "permission.saved.list": "listSaved", "permission.saved.remove": "removeSaved", "question.request.list": "listRequests", + // PA-10: both browser endpoints end in ".list", and the client method name is the last + // dot segment, so the location-wide one needs an explicit name or generation fails. + "browser.request.list": "listRequests", } as const export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index a0e209872b..bfff7296bd 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -18,32 +18,60 @@ const Endpoint0_0 = (raw: RawClient["server.health"]) => () => const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) }) -type Endpoint1_0Request = Parameters[0] -type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] } -const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) => +const Endpoint1_0 = (raw: RawClient["server.clients"]) => () => + raw["clients.list"]({}).pipe(Effect.mapError(mapClientError)) + +type Endpoint1_1Request = Parameters[0] +type Endpoint1_1Input = { + readonly app: Endpoint1_1Request["payload"]["app"] + readonly pid?: Endpoint1_1Request["payload"]["pid"] +} +const Endpoint1_1 = (raw: RawClient["server.clients"]) => (input: Endpoint1_1Input) => + raw["clients.attach"]({ payload: { app: input["app"], pid: input["pid"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint1_2Request = Parameters[0] +type Endpoint1_2Input = { readonly clientID: Endpoint1_2Request["params"]["clientID"] } +const Endpoint1_2 = (raw: RawClient["server.clients"]) => (input: Endpoint1_2Input) => + raw["clients.heartbeat"]({ params: { clientID: input["clientID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint1_3Request = Parameters[0] +type Endpoint1_3Input = { readonly clientID: Endpoint1_3Request["params"]["clientID"] } +const Endpoint1_3 = (raw: RawClient["server.clients"]) => (input: Endpoint1_3Input) => + raw["clients.detach"]({ params: { clientID: input["clientID"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup1 = (raw: RawClient["server.clients"]) => ({ + list: Endpoint1_0(raw), + attach: Endpoint1_1(raw), + heartbeat: Endpoint1_2(raw), + detach: Endpoint1_3(raw), +}) + +type Endpoint2_0Request = Parameters[0] +type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] } +const Endpoint2_0 = (raw: RawClient["server.location"]) => (input?: Endpoint2_0Input) => raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) }) +const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw) }) -type Endpoint2_0Request = Parameters[0] -type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] } -const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) => +type Endpoint3_0Request = Parameters[0] +type Endpoint3_0Input = { readonly location?: Endpoint3_0Request["query"]["location"] } +const Endpoint3_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint3_0Input) => raw["agent.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) }) +const adaptGroup3 = (raw: RawClient["server.agent"]) => ({ list: Endpoint3_0(raw) }) -type Endpoint3_0Request = Parameters[0] -type Endpoint3_0Input = { - readonly workspace?: Endpoint3_0Request["query"]["workspace"] - readonly limit?: Endpoint3_0Request["query"]["limit"] - readonly order?: Endpoint3_0Request["query"]["order"] - readonly search?: Endpoint3_0Request["query"]["search"] - readonly directory?: Endpoint3_0Request["query"]["directory"] - readonly project?: Endpoint3_0Request["query"]["project"] - readonly subpath?: Endpoint3_0Request["query"]["subpath"] - readonly cursor?: Endpoint3_0Request["query"]["cursor"] +type Endpoint4_0Request = Parameters[0] +type Endpoint4_0Input = { + readonly workspace?: Endpoint4_0Request["query"]["workspace"] + readonly limit?: Endpoint4_0Request["query"]["limit"] + readonly order?: Endpoint4_0Request["query"]["order"] + readonly search?: Endpoint4_0Request["query"]["search"] + readonly directory?: Endpoint4_0Request["query"]["directory"] + readonly project?: Endpoint4_0Request["query"]["project"] + readonly subpath?: Endpoint4_0Request["query"]["subpath"] + readonly cursor?: Endpoint4_0Request["query"]["cursor"] } -const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) => +const Endpoint4_0 = (raw: RawClient["server.session"]) => (input?: Endpoint4_0Input) => raw["session.list"]({ query: { workspace: input?.["workspace"], @@ -57,14 +85,14 @@ const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0In }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_1Request = Parameters[0] -type Endpoint3_1Input = { - readonly id?: Endpoint3_1Request["payload"]["id"] - readonly agent?: Endpoint3_1Request["payload"]["agent"] - readonly model?: Endpoint3_1Request["payload"]["model"] - readonly location?: Endpoint3_1Request["payload"]["location"] +type Endpoint4_1Request = Parameters[0] +type Endpoint4_1Input = { + readonly id?: Endpoint4_1Request["payload"]["id"] + readonly agent?: Endpoint4_1Request["payload"]["agent"] + readonly model?: Endpoint4_1Request["payload"]["model"] + readonly location?: Endpoint4_1Request["payload"]["location"] } -const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) => +const Endpoint4_1 = (raw: RawClient["server.session"]) => (input?: Endpoint4_1Input) => raw["session.create"]({ payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] }, }).pipe( @@ -72,49 +100,49 @@ const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1In Effect.map((value) => value.data), ) -const Endpoint3_2 = (raw: RawClient["server.session"]) => () => +const Endpoint4_2 = (raw: RawClient["server.session"]) => () => raw["session.active"]({}).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint3_3Request = Parameters[0] -type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] } -const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) => +type Endpoint4_3Request = Parameters[0] +type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] } +const Endpoint4_3 = (raw: RawClient["server.session"]) => (input: Endpoint4_3Input) => raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint3_4Request = Parameters[0] -type Endpoint3_4Input = { - readonly sessionID: Endpoint3_4Request["params"]["sessionID"] - readonly agent: Endpoint3_4Request["payload"]["agent"] +type Endpoint4_4Request = Parameters[0] +type Endpoint4_4Input = { + readonly sessionID: Endpoint4_4Request["params"]["sessionID"] + readonly agent: Endpoint4_4Request["payload"]["agent"] } -const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) => +const Endpoint4_4 = (raw: RawClient["server.session"]) => (input: Endpoint4_4Input) => raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint3_5Request = Parameters[0] -type Endpoint3_5Input = { - readonly sessionID: Endpoint3_5Request["params"]["sessionID"] - readonly model: Endpoint3_5Request["payload"]["model"] +type Endpoint4_5Request = Parameters[0] +type Endpoint4_5Input = { + readonly sessionID: Endpoint4_5Request["params"]["sessionID"] + readonly model: Endpoint4_5Request["payload"]["model"] } -const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) => +const Endpoint4_5 = (raw: RawClient["server.session"]) => (input: Endpoint4_5Input) => raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint3_6Request = Parameters[0] -type Endpoint3_6Input = { - readonly sessionID: Endpoint3_6Request["params"]["sessionID"] - readonly id?: Endpoint3_6Request["payload"]["id"] - readonly prompt: Endpoint3_6Request["payload"]["prompt"] - readonly delivery?: Endpoint3_6Request["payload"]["delivery"] - readonly resume?: Endpoint3_6Request["payload"]["resume"] +type Endpoint4_6Request = Parameters[0] +type Endpoint4_6Input = { + readonly sessionID: Endpoint4_6Request["params"]["sessionID"] + readonly id?: Endpoint4_6Request["payload"]["id"] + readonly prompt: Endpoint4_6Request["payload"]["prompt"] + readonly delivery?: Endpoint4_6Request["payload"]["delivery"] + readonly resume?: Endpoint4_6Request["payload"]["resume"] } -const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) => +const Endpoint4_6 = (raw: RawClient["server.session"]) => (input: Endpoint4_6Input) => raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, @@ -123,23 +151,23 @@ const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Inp Effect.map((value) => value.data), ) -type Endpoint3_7Request = Parameters[0] -type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] } -const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) => +type Endpoint4_7Request = Parameters[0] +type Endpoint4_7Input = { readonly sessionID: Endpoint4_7Request["params"]["sessionID"] } +const Endpoint4_7 = (raw: RawClient["server.session"]) => (input: Endpoint4_7Input) => raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_8Request = Parameters[0] -type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] } -const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) => +type Endpoint4_8Request = Parameters[0] +type Endpoint4_8Input = { readonly sessionID: Endpoint4_8Request["params"]["sessionID"] } +const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_9Request = Parameters[0] -type Endpoint3_9Input = { - readonly sessionID: Endpoint3_9Request["params"]["sessionID"] - readonly messageID: Endpoint3_9Request["payload"]["messageID"] - readonly files?: Endpoint3_9Request["payload"]["files"] +type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Input = { + readonly sessionID: Endpoint4_9Request["params"]["sessionID"] + readonly messageID: Endpoint4_9Request["payload"]["messageID"] + readonly files?: Endpoint4_9Request["payload"]["files"] } -const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) => +const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -148,42 +176,42 @@ const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Inp Effect.map((value) => value.data), ) -type Endpoint3_10Request = Parameters[0] -type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] } -const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => +type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] } +const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_11Request = Parameters[0] -type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] } -const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => +type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] } +const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_12Request = Parameters[0] -type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] } -const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => +type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } +const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint3_13Request = Parameters[0] -type Endpoint3_13Input = { - readonly sessionID: Endpoint3_13Request["params"]["sessionID"] - readonly limit?: Endpoint3_13Request["query"]["limit"] - readonly after?: Endpoint3_13Request["query"]["after"] +type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Input = { + readonly sessionID: Endpoint4_13Request["params"]["sessionID"] + readonly limit?: Endpoint4_13Request["query"]["limit"] + readonly after?: Endpoint4_13Request["query"]["after"] } -const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) => +const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => raw["session.history"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], after: input["after"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_14Request = Parameters[0] -type Endpoint3_14Input = { - readonly sessionID: Endpoint3_14Request["params"]["sessionID"] - readonly after?: Endpoint3_14Request["query"]["after"] +type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Input = { + readonly sessionID: Endpoint4_14Request["params"]["sessionID"] + readonly after?: Endpoint4_14Request["query"]["after"] } -const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) => +const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( Effect.mapError(mapClientError), @@ -191,251 +219,270 @@ const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14I ), ) -type Endpoint3_15Request = Parameters[0] -type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] } -const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => +type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } +const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_16Request = Parameters[0] -type Endpoint3_16Input = { - readonly sessionID: Endpoint3_16Request["params"]["sessionID"] - readonly messageID: Endpoint3_16Request["params"]["messageID"] +type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Input = { + readonly sessionID: Endpoint4_16Request["params"]["sessionID"] + readonly messageID: Endpoint4_16Request["params"]["messageID"] } -const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => +const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -const adaptGroup3 = (raw: RawClient["server.session"]) => ({ - list: Endpoint3_0(raw), - create: Endpoint3_1(raw), - active: Endpoint3_2(raw), - get: Endpoint3_3(raw), - switchAgent: Endpoint3_4(raw), - switchModel: Endpoint3_5(raw), - prompt: Endpoint3_6(raw), - compact: Endpoint3_7(raw), - wait: Endpoint3_8(raw), - stage: Endpoint3_9(raw), - clear: Endpoint3_10(raw), - commit: Endpoint3_11(raw), - context: Endpoint3_12(raw), - history: Endpoint3_13(raw), - events: Endpoint3_14(raw), - interrupt: Endpoint3_15(raw), - message: Endpoint3_16(raw), +const adaptGroup4 = (raw: RawClient["server.session"]) => ({ + list: Endpoint4_0(raw), + create: Endpoint4_1(raw), + active: Endpoint4_2(raw), + get: Endpoint4_3(raw), + switchAgent: Endpoint4_4(raw), + switchModel: Endpoint4_5(raw), + prompt: Endpoint4_6(raw), + compact: Endpoint4_7(raw), + wait: Endpoint4_8(raw), + stage: Endpoint4_9(raw), + clear: Endpoint4_10(raw), + commit: Endpoint4_11(raw), + context: Endpoint4_12(raw), + history: Endpoint4_13(raw), + events: Endpoint4_14(raw), + interrupt: Endpoint4_15(raw), + message: Endpoint4_16(raw), }) -type Endpoint4_0Request = Parameters[0] -type Endpoint4_0Input = { - readonly sessionID: Endpoint4_0Request["params"]["sessionID"] - readonly limit?: Endpoint4_0Request["query"]["limit"] - readonly order?: Endpoint4_0Request["query"]["order"] - readonly cursor?: Endpoint4_0Request["query"]["cursor"] +type Endpoint5_0Request = Parameters[0] +type Endpoint5_0Input = { + readonly sessionID: Endpoint5_0Request["params"]["sessionID"] + readonly limit?: Endpoint5_0Request["query"]["limit"] + readonly order?: Endpoint5_0Request["query"]["order"] + readonly cursor?: Endpoint5_0Request["query"]["cursor"] } -const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) => +const Endpoint5_0 = (raw: RawClient["server.message"]) => (input: Endpoint5_0Input) => raw["session.messages"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], order: input["order"], cursor: input["cursor"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) }) +const adaptGroup5 = (raw: RawClient["server.message"]) => ({ list: Endpoint5_0(raw) }) -type Endpoint5_0Request = Parameters[0] -type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] } -const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) => +type Endpoint6_0Request = Parameters[0] +type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] } +const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) => raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) }) +const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw) }) -type Endpoint6_0Request = Parameters[0] -type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] } -const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) => +type Endpoint7_0Request = Parameters[0] +type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] } +const Endpoint7_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint7_0Input) => raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint6_1Request = Parameters[0] -type Endpoint6_1Input = { - readonly providerID: Endpoint6_1Request["params"]["providerID"] - readonly location?: Endpoint6_1Request["query"]["location"] +type Endpoint7_1Request = Parameters[0] +type Endpoint7_1Input = { + readonly providerID: Endpoint7_1Request["params"]["providerID"] + readonly location?: Endpoint7_1Request["query"]["location"] } -const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) => +const Endpoint7_1 = (raw: RawClient["server.provider"]) => (input: Endpoint7_1Input) => raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) }) +const adaptGroup7 = (raw: RawClient["server.provider"]) => ({ list: Endpoint7_0(raw), get: Endpoint7_1(raw) }) -type Endpoint7_0Request = Parameters[0] -type Endpoint7_0Input = { - readonly text: Endpoint7_0Request["payload"]["text"] - readonly models: Endpoint7_0Request["payload"]["models"] - readonly requestId?: Endpoint7_0Request["payload"]["requestId"] +type Endpoint8_0Request = Parameters[0] +type Endpoint8_0Input = { + readonly text: Endpoint8_0Request["payload"]["text"] + readonly models: Endpoint8_0Request["payload"]["models"] + readonly requestId?: Endpoint8_0Request["payload"]["requestId"] } -const Endpoint7_0 = (raw: RawClient["server.variant"]) => (input: Endpoint7_0Input) => +const Endpoint8_0 = (raw: RawClient["server.variant"]) => (input: Endpoint8_0Input) => raw["variant.paraphrase"]({ payload: { text: input["text"], models: input["models"], requestId: input["requestId"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint7_1Request = Parameters[0] -type Endpoint7_1Input = { - readonly messages: Endpoint7_1Request["payload"]["messages"] - readonly models: Endpoint7_1Request["payload"]["models"] - readonly requestId?: Endpoint7_1Request["payload"]["requestId"] +type Endpoint8_1Request = Parameters[0] +type Endpoint8_1Input = { + readonly messages: Endpoint8_1Request["payload"]["messages"] + readonly models: Endpoint8_1Request["payload"]["models"] + readonly requestId?: Endpoint8_1Request["payload"]["requestId"] } -const Endpoint7_1 = (raw: RawClient["server.variant"]) => (input: Endpoint7_1Input) => +const Endpoint8_1 = (raw: RawClient["server.variant"]) => (input: Endpoint8_1Input) => raw["variant.compare"]({ payload: { messages: input["messages"], models: input["models"], requestId: input["requestId"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup7 = (raw: RawClient["server.variant"]) => ({ paraphrase: Endpoint7_0(raw), compare: Endpoint7_1(raw) }) +const adaptGroup8 = (raw: RawClient["server.variant"]) => ({ paraphrase: Endpoint8_0(raw), compare: Endpoint8_1(raw) }) -type Endpoint8_0Request = Parameters[0] -type Endpoint8_0Input = { readonly location?: Endpoint8_0Request["query"]["location"] } -const Endpoint8_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint8_0Input) => +type Endpoint9_0Request = Parameters[0] +type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } +const Endpoint9_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint9_0Input) => raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint8_1Request = Parameters[0] -type Endpoint8_1Input = { - readonly integrationID: Endpoint8_1Request["params"]["integrationID"] - readonly location?: Endpoint8_1Request["query"]["location"] +type Endpoint9_1Request = Parameters[0] +type Endpoint9_1Input = { + readonly integrationID: Endpoint9_1Request["params"]["integrationID"] + readonly location?: Endpoint9_1Request["query"]["location"] } -const Endpoint8_1 = (raw: RawClient["server.integration"]) => (input: Endpoint8_1Input) => +const Endpoint9_1 = (raw: RawClient["server.integration"]) => (input: Endpoint9_1Input) => raw["integration.get"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint8_2Request = Parameters[0] -type Endpoint8_2Input = { - readonly integrationID: Endpoint8_2Request["params"]["integrationID"] - readonly location?: Endpoint8_2Request["query"]["location"] - readonly key: Endpoint8_2Request["payload"]["key"] - readonly label?: Endpoint8_2Request["payload"]["label"] +type Endpoint9_2Request = Parameters[0] +type Endpoint9_2Input = { + readonly integrationID: Endpoint9_2Request["params"]["integrationID"] + readonly location?: Endpoint9_2Request["query"]["location"] + readonly key: Endpoint9_2Request["payload"]["key"] + readonly label?: Endpoint9_2Request["payload"]["label"] } -const Endpoint8_2 = (raw: RawClient["server.integration"]) => (input: Endpoint8_2Input) => +const Endpoint9_2 = (raw: RawClient["server.integration"]) => (input: Endpoint9_2Input) => raw["integration.connect.key"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint8_3Request = Parameters[0] -type Endpoint8_3Input = { - readonly integrationID: Endpoint8_3Request["params"]["integrationID"] - readonly location?: Endpoint8_3Request["query"]["location"] - readonly methodID: Endpoint8_3Request["payload"]["methodID"] - readonly inputs: Endpoint8_3Request["payload"]["inputs"] - readonly label?: Endpoint8_3Request["payload"]["label"] +type Endpoint9_3Request = Parameters[0] +type Endpoint9_3Input = { + readonly integrationID: Endpoint9_3Request["params"]["integrationID"] + readonly location?: Endpoint9_3Request["query"]["location"] + readonly methodID: Endpoint9_3Request["payload"]["methodID"] + readonly inputs: Endpoint9_3Request["payload"]["inputs"] + readonly label?: Endpoint9_3Request["payload"]["label"] } -const Endpoint8_3 = (raw: RawClient["server.integration"]) => (input: Endpoint8_3Input) => +const Endpoint9_3 = (raw: RawClient["server.integration"]) => (input: Endpoint9_3Input) => raw["integration.connect.oauth"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint8_4Request = Parameters[0] -type Endpoint8_4Input = { - readonly attemptID: Endpoint8_4Request["params"]["attemptID"] - readonly location?: Endpoint8_4Request["query"]["location"] +type Endpoint9_4Request = Parameters[0] +type Endpoint9_4Input = { + readonly attemptID: Endpoint9_4Request["params"]["attemptID"] + readonly location?: Endpoint9_4Request["query"]["location"] } -const Endpoint8_4 = (raw: RawClient["server.integration"]) => (input: Endpoint8_4Input) => +const Endpoint9_4 = (raw: RawClient["server.integration"]) => (input: Endpoint9_4Input) => raw["integration.attempt.status"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint8_5Request = Parameters[0] -type Endpoint8_5Input = { - readonly attemptID: Endpoint8_5Request["params"]["attemptID"] - readonly location?: Endpoint8_5Request["query"]["location"] - readonly code?: Endpoint8_5Request["payload"]["code"] +type Endpoint9_5Request = Parameters[0] +type Endpoint9_5Input = { + readonly attemptID: Endpoint9_5Request["params"]["attemptID"] + readonly location?: Endpoint9_5Request["query"]["location"] + readonly code?: Endpoint9_5Request["payload"]["code"] } -const Endpoint8_5 = (raw: RawClient["server.integration"]) => (input: Endpoint8_5Input) => +const Endpoint9_5 = (raw: RawClient["server.integration"]) => (input: Endpoint9_5Input) => raw["integration.attempt.complete"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint8_6Request = Parameters[0] -type Endpoint8_6Input = { - readonly attemptID: Endpoint8_6Request["params"]["attemptID"] - readonly location?: Endpoint8_6Request["query"]["location"] +type Endpoint9_6Request = Parameters[0] +type Endpoint9_6Input = { + readonly attemptID: Endpoint9_6Request["params"]["attemptID"] + readonly location?: Endpoint9_6Request["query"]["location"] } -const Endpoint8_6 = (raw: RawClient["server.integration"]) => (input: Endpoint8_6Input) => +const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_6Input) => raw["integration.attempt.cancel"]({ params: { attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup8 = (raw: RawClient["server.integration"]) => ({ - list: Endpoint8_0(raw), - get: Endpoint8_1(raw), - connectKey: Endpoint8_2(raw), - connectOauth: Endpoint8_3(raw), - attemptStatus: Endpoint8_4(raw), - attemptComplete: Endpoint8_5(raw), - attemptCancel: Endpoint8_6(raw), +const adaptGroup9 = (raw: RawClient["server.integration"]) => ({ + list: Endpoint9_0(raw), + get: Endpoint9_1(raw), + connectKey: Endpoint9_2(raw), + connectOauth: Endpoint9_3(raw), + attemptStatus: Endpoint9_4(raw), + attemptComplete: Endpoint9_5(raw), + attemptCancel: Endpoint9_6(raw), }) -type Endpoint9_0Request = Parameters[0] -type Endpoint9_0Input = { - readonly credentialID: Endpoint9_0Request["params"]["credentialID"] - readonly location?: Endpoint9_0Request["query"]["location"] - readonly label: Endpoint9_0Request["payload"]["label"] +type Endpoint10_0Request = Parameters[0] +type Endpoint10_0Input = { + readonly credentialID: Endpoint10_0Request["params"]["credentialID"] + readonly location?: Endpoint10_0Request["query"]["location"] + readonly label: Endpoint10_0Request["payload"]["label"] } -const Endpoint9_0 = (raw: RawClient["server.credential"]) => (input: Endpoint9_0Input) => +const Endpoint10_0 = (raw: RawClient["server.credential"]) => (input: Endpoint10_0Input) => raw["credential.update"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, payload: { label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_1Request = Parameters[0] -type Endpoint9_1Input = { - readonly credentialID: Endpoint9_1Request["params"]["credentialID"] - readonly location?: Endpoint9_1Request["query"]["location"] +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { + readonly credentialID: Endpoint10_1Request["params"]["credentialID"] + readonly location?: Endpoint10_1Request["query"]["location"] } -const Endpoint9_1 = (raw: RawClient["server.credential"]) => (input: Endpoint9_1Input) => +const Endpoint10_1 = (raw: RawClient["server.credential"]) => (input: Endpoint10_1Input) => raw["credential.remove"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup9 = (raw: RawClient["server.credential"]) => ({ update: Endpoint9_0(raw), remove: Endpoint9_1(raw) }) +const adaptGroup10 = (raw: RawClient["server.credential"]) => ({ update: Endpoint10_0(raw), remove: Endpoint10_1(raw) }) + +type Endpoint11_0Request = Parameters[0] +type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } +const Endpoint11_0 = (raw: RawClient["server.privacy"]) => (input?: Endpoint11_0Input) => + raw["privacy.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint11_1Request = Parameters[0] +type Endpoint11_1Input = { + readonly location?: Endpoint11_1Request["query"]["location"] + readonly mode: Endpoint11_1Request["payload"]["mode"] + readonly model?: Endpoint11_1Request["payload"]["model"] +} +const Endpoint11_1 = (raw: RawClient["server.privacy"]) => (input: Endpoint11_1Input) => + raw["privacy.set"]({ + query: { location: input["location"] }, + payload: { mode: input["mode"], model: input["model"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup11 = (raw: RawClient["server.privacy"]) => ({ get: Endpoint11_0(raw), set: Endpoint11_1(raw) }) -type Endpoint10_0Request = Parameters[0] -type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } -const Endpoint10_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_0Input) => +type Endpoint12_0Request = Parameters[0] +type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } +const Endpoint12_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint12_0Input) => raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_1Request = Parameters[0] -type Endpoint10_1Input = { readonly projectID?: Endpoint10_1Request["query"]["projectID"] } -const Endpoint10_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_1Input) => +type Endpoint12_1Request = Parameters[0] +type Endpoint12_1Input = { readonly projectID?: Endpoint12_1Request["query"]["projectID"] } +const Endpoint12_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint12_1Input) => raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint10_2Request = Parameters[0] -type Endpoint10_2Input = { readonly id: Endpoint10_2Request["params"]["id"] } -const Endpoint10_2 = (raw: RawClient["server.permission"]) => (input: Endpoint10_2Input) => +type Endpoint12_2Request = Parameters[0] +type Endpoint12_2Input = { readonly id: Endpoint12_2Request["params"]["id"] } +const Endpoint12_2 = (raw: RawClient["server.permission"]) => (input: Endpoint12_2Input) => raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_3Request = Parameters[0] -type Endpoint10_3Input = { - readonly sessionID: Endpoint10_3Request["params"]["sessionID"] - readonly id?: Endpoint10_3Request["payload"]["id"] - readonly action: Endpoint10_3Request["payload"]["action"] - readonly resources: Endpoint10_3Request["payload"]["resources"] - readonly save?: Endpoint10_3Request["payload"]["save"] - readonly metadata?: Endpoint10_3Request["payload"]["metadata"] - readonly source?: Endpoint10_3Request["payload"]["source"] - readonly agent?: Endpoint10_3Request["payload"]["agent"] -} -const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10_3Input) => +type Endpoint12_3Request = Parameters[0] +type Endpoint12_3Input = { + readonly sessionID: Endpoint12_3Request["params"]["sessionID"] + readonly id?: Endpoint12_3Request["payload"]["id"] + readonly action: Endpoint12_3Request["payload"]["action"] + readonly resources: Endpoint12_3Request["payload"]["resources"] + readonly save?: Endpoint12_3Request["payload"]["save"] + readonly metadata?: Endpoint12_3Request["payload"]["metadata"] + readonly source?: Endpoint12_3Request["payload"]["source"] + readonly agent?: Endpoint12_3Request["payload"]["agent"] +} +const Endpoint12_3 = (raw: RawClient["server.permission"]) => (input: Endpoint12_3Input) => raw["session.permission.create"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -452,87 +499,87 @@ const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10 Effect.map((value) => value.data), ) -type Endpoint10_4Request = Parameters[0] -type Endpoint10_4Input = { readonly sessionID: Endpoint10_4Request["params"]["sessionID"] } -const Endpoint10_4 = (raw: RawClient["server.permission"]) => (input: Endpoint10_4Input) => +type Endpoint12_4Request = Parameters[0] +type Endpoint12_4Input = { readonly sessionID: Endpoint12_4Request["params"]["sessionID"] } +const Endpoint12_4 = (raw: RawClient["server.permission"]) => (input: Endpoint12_4Input) => raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint10_5Request = Parameters[0] -type Endpoint10_5Input = { - readonly sessionID: Endpoint10_5Request["params"]["sessionID"] - readonly requestID: Endpoint10_5Request["params"]["requestID"] +type Endpoint12_5Request = Parameters[0] +type Endpoint12_5Input = { + readonly sessionID: Endpoint12_5Request["params"]["sessionID"] + readonly requestID: Endpoint12_5Request["params"]["requestID"] } -const Endpoint10_5 = (raw: RawClient["server.permission"]) => (input: Endpoint10_5Input) => +const Endpoint12_5 = (raw: RawClient["server.permission"]) => (input: Endpoint12_5Input) => raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint10_6Request = Parameters[0] -type Endpoint10_6Input = { - readonly sessionID: Endpoint10_6Request["params"]["sessionID"] - readonly requestID: Endpoint10_6Request["params"]["requestID"] - readonly reply: Endpoint10_6Request["payload"]["reply"] - readonly message?: Endpoint10_6Request["payload"]["message"] +type Endpoint12_6Request = Parameters[0] +type Endpoint12_6Input = { + readonly sessionID: Endpoint12_6Request["params"]["sessionID"] + readonly requestID: Endpoint12_6Request["params"]["requestID"] + readonly reply: Endpoint12_6Request["payload"]["reply"] + readonly message?: Endpoint12_6Request["payload"]["message"] } -const Endpoint10_6 = (raw: RawClient["server.permission"]) => (input: Endpoint10_6Input) => +const Endpoint12_6 = (raw: RawClient["server.permission"]) => (input: Endpoint12_6Input) => raw["session.permission.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { reply: input["reply"], message: input["message"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.permission"]) => ({ - listRequests: Endpoint10_0(raw), - listSaved: Endpoint10_1(raw), - removeSaved: Endpoint10_2(raw), - create: Endpoint10_3(raw), - list: Endpoint10_4(raw), - get: Endpoint10_5(raw), - reply: Endpoint10_6(raw), +const adaptGroup12 = (raw: RawClient["server.permission"]) => ({ + listRequests: Endpoint12_0(raw), + listSaved: Endpoint12_1(raw), + removeSaved: Endpoint12_2(raw), + create: Endpoint12_3(raw), + list: Endpoint12_4(raw), + get: Endpoint12_5(raw), + reply: Endpoint12_6(raw), }) -type Endpoint11_0Request = Parameters[0] -type Endpoint11_0Input = { - readonly location?: Endpoint11_0Request["query"]["location"] - readonly path?: Endpoint11_0Request["query"]["path"] +type Endpoint13_0Request = Parameters[0] +type Endpoint13_0Input = { + readonly location?: Endpoint13_0Request["query"]["location"] + readonly path?: Endpoint13_0Request["query"]["path"] } -const Endpoint11_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint11_0Input) => +const Endpoint13_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint13_0Input) => raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint11_1Request = Parameters[0] -type Endpoint11_1Input = { - readonly location?: Endpoint11_1Request["query"]["location"] - readonly query: Endpoint11_1Request["query"]["query"] - readonly type?: Endpoint11_1Request["query"]["type"] - readonly limit?: Endpoint11_1Request["query"]["limit"] +type Endpoint13_1Request = Parameters[0] +type Endpoint13_1Input = { + readonly location?: Endpoint13_1Request["query"]["location"] + readonly query: Endpoint13_1Request["query"]["query"] + readonly type?: Endpoint13_1Request["query"]["type"] + readonly limit?: Endpoint13_1Request["query"]["limit"] } -const Endpoint11_1 = (raw: RawClient["server.fs"]) => (input: Endpoint11_1Input) => +const Endpoint13_1 = (raw: RawClient["server.fs"]) => (input: Endpoint13_1Input) => raw["fs.find"]({ query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup11 = (raw: RawClient["server.fs"]) => ({ list: Endpoint11_0(raw), find: Endpoint11_1(raw) }) +const adaptGroup13 = (raw: RawClient["server.fs"]) => ({ list: Endpoint13_0(raw), find: Endpoint13_1(raw) }) -type Endpoint12_0Request = Parameters[0] -type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } -const Endpoint12_0 = (raw: RawClient["server.command"]) => (input?: Endpoint12_0Input) => +type Endpoint14_0Request = Parameters[0] +type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } +const Endpoint14_0 = (raw: RawClient["server.command"]) => (input?: Endpoint14_0Input) => raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup12 = (raw: RawClient["server.command"]) => ({ list: Endpoint12_0(raw) }) +const adaptGroup14 = (raw: RawClient["server.command"]) => ({ list: Endpoint14_0(raw) }) -type Endpoint13_0Request = Parameters[0] -type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } -const Endpoint13_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint13_0Input) => +type Endpoint15_0Request = Parameters[0] +type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } +const Endpoint15_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint15_0Input) => raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup13 = (raw: RawClient["server.skill"]) => ({ list: Endpoint13_0(raw) }) +const adaptGroup15 = (raw: RawClient["server.skill"]) => ({ list: Endpoint15_0(raw) }) -const Endpoint14_0 = (raw: RawClient["server.event"]) => () => +const Endpoint16_0 = (raw: RawClient["server.event"]) => () => Stream.unwrap( raw["event.subscribe"]({}).pipe( Effect.mapError(mapClientError), @@ -540,23 +587,23 @@ const Endpoint14_0 = (raw: RawClient["server.event"]) => () => ), ) -const adaptGroup14 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint14_0(raw) }) +const adaptGroup16 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint16_0(raw) }) -type Endpoint15_0Request = Parameters[0] -type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } -const Endpoint15_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_0Input) => +type Endpoint17_0Request = Parameters[0] +type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +const Endpoint17_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint17_0Input) => raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_1Request = Parameters[0] -type Endpoint15_1Input = { - readonly location?: Endpoint15_1Request["query"]["location"] - readonly command?: Endpoint15_1Request["payload"]["command"] - readonly args?: Endpoint15_1Request["payload"]["args"] - readonly cwd?: Endpoint15_1Request["payload"]["cwd"] - readonly title?: Endpoint15_1Request["payload"]["title"] - readonly env?: Endpoint15_1Request["payload"]["env"] +type Endpoint17_1Request = Parameters[0] +type Endpoint17_1Input = { + readonly location?: Endpoint17_1Request["query"]["location"] + readonly command?: Endpoint17_1Request["payload"]["command"] + readonly args?: Endpoint17_1Request["payload"]["args"] + readonly cwd?: Endpoint17_1Request["payload"]["cwd"] + readonly title?: Endpoint17_1Request["payload"]["title"] + readonly env?: Endpoint17_1Request["payload"]["env"] } -const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Input) => +const Endpoint17_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint17_1Input) => raw["pty.create"]({ query: { location: input?.["location"] }, payload: { @@ -568,163 +615,252 @@ const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Inpu }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_2Request = Parameters[0] -type Endpoint15_2Input = { - readonly ptyID: Endpoint15_2Request["params"]["ptyID"] - readonly location?: Endpoint15_2Request["query"]["location"] +type Endpoint17_2Request = Parameters[0] +type Endpoint17_2Input = { + readonly ptyID: Endpoint17_2Request["params"]["ptyID"] + readonly location?: Endpoint17_2Request["query"]["location"] } -const Endpoint15_2 = (raw: RawClient["server.pty"]) => (input: Endpoint15_2Input) => +const Endpoint17_2 = (raw: RawClient["server.pty"]) => (input: Endpoint17_2Input) => raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint15_3Request = Parameters[0] -type Endpoint15_3Input = { - readonly ptyID: Endpoint15_3Request["params"]["ptyID"] - readonly location?: Endpoint15_3Request["query"]["location"] - readonly title?: Endpoint15_3Request["payload"]["title"] - readonly size?: Endpoint15_3Request["payload"]["size"] +type Endpoint17_3Request = Parameters[0] +type Endpoint17_3Input = { + readonly ptyID: Endpoint17_3Request["params"]["ptyID"] + readonly location?: Endpoint17_3Request["query"]["location"] + readonly title?: Endpoint17_3Request["payload"]["title"] + readonly size?: Endpoint17_3Request["payload"]["size"] } -const Endpoint15_3 = (raw: RawClient["server.pty"]) => (input: Endpoint15_3Input) => +const Endpoint17_3 = (raw: RawClient["server.pty"]) => (input: Endpoint17_3Input) => raw["pty.update"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] }, payload: { title: input["title"], size: input["size"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_4Request = Parameters[0] -type Endpoint15_4Input = { - readonly ptyID: Endpoint15_4Request["params"]["ptyID"] - readonly location?: Endpoint15_4Request["query"]["location"] +type Endpoint17_4Request = Parameters[0] +type Endpoint17_4Input = { + readonly ptyID: Endpoint17_4Request["params"]["ptyID"] + readonly location?: Endpoint17_4Request["query"]["location"] } -const Endpoint15_4 = (raw: RawClient["server.pty"]) => (input: Endpoint15_4Input) => +const Endpoint17_4 = (raw: RawClient["server.pty"]) => (input: Endpoint17_4Input) => raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup15 = (raw: RawClient["server.pty"]) => ({ - list: Endpoint15_0(raw), - create: Endpoint15_1(raw), - get: Endpoint15_2(raw), - update: Endpoint15_3(raw), - remove: Endpoint15_4(raw), +const adaptGroup17 = (raw: RawClient["server.pty"]) => ({ + list: Endpoint17_0(raw), + create: Endpoint17_1(raw), + get: Endpoint17_2(raw), + update: Endpoint17_3(raw), + remove: Endpoint17_4(raw), }) -type Endpoint16_0Request = Parameters[0] -type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } -const Endpoint16_0 = (raw: RawClient["server.question"]) => (input?: Endpoint16_0Input) => +type Endpoint18_0Request = Parameters[0] +type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } +const Endpoint18_0 = (raw: RawClient["server.question"]) => (input?: Endpoint18_0Input) => raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_1Request = Parameters[0] -type Endpoint16_1Input = { readonly sessionID: Endpoint16_1Request["params"]["sessionID"] } -const Endpoint16_1 = (raw: RawClient["server.question"]) => (input: Endpoint16_1Input) => +type Endpoint18_1Request = Parameters[0] +type Endpoint18_1Input = { readonly sessionID: Endpoint18_1Request["params"]["sessionID"] } +const Endpoint18_1 = (raw: RawClient["server.question"]) => (input: Endpoint18_1Input) => raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint16_2Request = Parameters[0] -type Endpoint16_2Input = { - readonly sessionID: Endpoint16_2Request["params"]["sessionID"] - readonly requestID: Endpoint16_2Request["params"]["requestID"] - readonly answers: Endpoint16_2Request["payload"]["answers"] +type Endpoint18_2Request = Parameters[0] +type Endpoint18_2Input = { + readonly sessionID: Endpoint18_2Request["params"]["sessionID"] + readonly requestID: Endpoint18_2Request["params"]["requestID"] + readonly answers: Endpoint18_2Request["payload"]["answers"] } -const Endpoint16_2 = (raw: RawClient["server.question"]) => (input: Endpoint16_2Input) => +const Endpoint18_2 = (raw: RawClient["server.question"]) => (input: Endpoint18_2Input) => raw["session.question.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { answers: input["answers"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_3Request = Parameters[0] -type Endpoint16_3Input = { - readonly sessionID: Endpoint16_3Request["params"]["sessionID"] - readonly requestID: Endpoint16_3Request["params"]["requestID"] +type Endpoint18_3Request = Parameters[0] +type Endpoint18_3Input = { + readonly sessionID: Endpoint18_3Request["params"]["sessionID"] + readonly requestID: Endpoint18_3Request["params"]["requestID"] } -const Endpoint16_3 = (raw: RawClient["server.question"]) => (input: Endpoint16_3Input) => +const Endpoint18_3 = (raw: RawClient["server.question"]) => (input: Endpoint18_3Input) => raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup16 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint16_0(raw), - list: Endpoint16_1(raw), - reply: Endpoint16_2(raw), - reject: Endpoint16_3(raw), +const adaptGroup18 = (raw: RawClient["server.question"]) => ({ + listRequests: Endpoint18_0(raw), + list: Endpoint18_1(raw), + reply: Endpoint18_2(raw), + reject: Endpoint18_3(raw), }) -type Endpoint17_0Request = Parameters[0] -type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } -const Endpoint17_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint17_0Input) => +type Endpoint19_0Request = Parameters[0] +type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] } +const Endpoint19_0 = (raw: RawClient["server.browser"]) => (input?: Endpoint19_0Input) => + raw["browser.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint19_1Request = Parameters[0] +type Endpoint19_1Input = { readonly sessionID: Endpoint19_1Request["params"]["sessionID"] } +const Endpoint19_1 = (raw: RawClient["server.browser"]) => (input: Endpoint19_1Input) => + raw["session.browser.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint19_2Request = Parameters[0] +type Endpoint19_2Input = { + readonly sessionID: Endpoint19_2Request["params"]["sessionID"] + readonly requestID: Endpoint19_2Request["params"]["requestID"] + readonly value: Endpoint19_2Request["payload"]["value"] +} +const Endpoint19_2 = (raw: RawClient["server.browser"]) => (input: Endpoint19_2Input) => + raw["session.browser.reply"]({ + params: { sessionID: input["sessionID"], requestID: input["requestID"] }, + payload: { value: input["value"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint19_3Request = Parameters[0] +type Endpoint19_3Input = { + readonly sessionID: Endpoint19_3Request["params"]["sessionID"] + readonly requestID: Endpoint19_3Request["params"]["requestID"] + readonly reason: Endpoint19_3Request["payload"]["reason"] +} +const Endpoint19_3 = (raw: RawClient["server.browser"]) => (input: Endpoint19_3Input) => + raw["session.browser.refuse"]({ + params: { sessionID: input["sessionID"], requestID: input["requestID"] }, + payload: { reason: input["reason"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup19 = (raw: RawClient["server.browser"]) => ({ + listRequests: Endpoint19_0(raw), + list: Endpoint19_1(raw), + reply: Endpoint19_2(raw), + refuse: Endpoint19_3(raw), +}) + +type Endpoint20_0Request = Parameters[0] +type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] } +const Endpoint20_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint20_0Input) => raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup17 = (raw: RawClient["server.reference"]) => ({ list: Endpoint17_0(raw) }) +const adaptGroup20 = (raw: RawClient["server.reference"]) => ({ list: Endpoint20_0(raw) }) -type Endpoint18_0Request = Parameters[0] -type Endpoint18_0Input = { - readonly projectID: Endpoint18_0Request["params"]["projectID"] - readonly location?: Endpoint18_0Request["query"]["location"] - readonly strategy: Endpoint18_0Request["payload"]["strategy"] - readonly directory: Endpoint18_0Request["payload"]["directory"] - readonly name?: Endpoint18_0Request["payload"]["name"] +type Endpoint21_0Request = Parameters[0] +type Endpoint21_0Input = { + readonly projectID: Endpoint21_0Request["params"]["projectID"] + readonly location?: Endpoint21_0Request["query"]["location"] + readonly strategy: Endpoint21_0Request["payload"]["strategy"] + readonly directory: Endpoint21_0Request["payload"]["directory"] + readonly name?: Endpoint21_0Request["payload"]["name"] } -const Endpoint18_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_0Input) => +const Endpoint21_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_0Input) => raw["projectCopy.create"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint18_1Request = Parameters[0] -type Endpoint18_1Input = { - readonly projectID: Endpoint18_1Request["params"]["projectID"] - readonly location?: Endpoint18_1Request["query"]["location"] - readonly directory: Endpoint18_1Request["payload"]["directory"] - readonly force: Endpoint18_1Request["payload"]["force"] +type Endpoint21_1Request = Parameters[0] +type Endpoint21_1Input = { + readonly projectID: Endpoint21_1Request["params"]["projectID"] + readonly location?: Endpoint21_1Request["query"]["location"] + readonly directory: Endpoint21_1Request["payload"]["directory"] + readonly force: Endpoint21_1Request["payload"]["force"] } -const Endpoint18_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_1Input) => +const Endpoint21_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_1Input) => raw["projectCopy.remove"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { directory: input["directory"], force: input["force"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint18_2Request = Parameters[0] -type Endpoint18_2Input = { - readonly projectID: Endpoint18_2Request["params"]["projectID"] - readonly location?: Endpoint18_2Request["query"]["location"] +type Endpoint21_2Request = Parameters[0] +type Endpoint21_2Input = { + readonly projectID: Endpoint21_2Request["params"]["projectID"] + readonly location?: Endpoint21_2Request["query"]["location"] } -const Endpoint18_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint18_2Input) => +const Endpoint21_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_2Input) => raw["projectCopy.refresh"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup18 = (raw: RawClient["server.projectCopy"]) => ({ - create: Endpoint18_0(raw), - remove: Endpoint18_1(raw), - refresh: Endpoint18_2(raw), +const adaptGroup21 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint21_0(raw), + remove: Endpoint21_1(raw), + refresh: Endpoint21_2(raw), }) +type Endpoint22_0Request = Parameters[0] +type Endpoint22_0Input = { + readonly model: Endpoint22_0Request["payload"]["model"] + readonly messages: Endpoint22_0Request["payload"]["messages"] + readonly tools?: Endpoint22_0Request["payload"]["tools"] + readonly tool_choice?: Endpoint22_0Request["payload"]["tool_choice"] + readonly stream?: Endpoint22_0Request["payload"]["stream"] + readonly max_tokens?: Endpoint22_0Request["payload"]["max_tokens"] + readonly max_completion_tokens?: Endpoint22_0Request["payload"]["max_completion_tokens"] + readonly temperature?: Endpoint22_0Request["payload"]["temperature"] + readonly top_p?: Endpoint22_0Request["payload"]["top_p"] + readonly stop?: Endpoint22_0Request["payload"]["stop"] + readonly seed?: Endpoint22_0Request["payload"]["seed"] + readonly frequency_penalty?: Endpoint22_0Request["payload"]["frequency_penalty"] + readonly presence_penalty?: Endpoint22_0Request["payload"]["presence_penalty"] + readonly reasoning_effort?: Endpoint22_0Request["payload"]["reasoning_effort"] + readonly user?: Endpoint22_0Request["payload"]["user"] +} +const Endpoint22_0 = (raw: RawClient["server.chat"]) => (input: Endpoint22_0Input) => + raw["chat.completions"]({ + payload: { + model: input["model"], + messages: input["messages"], + tools: input["tools"], + tool_choice: input["tool_choice"], + stream: input["stream"], + max_tokens: input["max_tokens"], + max_completion_tokens: input["max_completion_tokens"], + temperature: input["temperature"], + top_p: input["top_p"], + stop: input["stop"], + seed: input["seed"], + frequency_penalty: input["frequency_penalty"], + presence_penalty: input["presence_penalty"], + reasoning_effort: input["reasoning_effort"], + user: input["user"], + }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup22 = (raw: RawClient["server.chat"]) => ({ completions: Endpoint22_0(raw) }) + const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), - location: adaptGroup1(raw["server.location"]), - agents: adaptGroup2(raw["server.agent"]), - sessions: adaptGroup3(raw["server.session"]), - messages: adaptGroup4(raw["server.message"]), - models: adaptGroup5(raw["server.model"]), - providers: adaptGroup6(raw["server.provider"]), - "server.variant": adaptGroup7(raw["server.variant"]), - integrations: adaptGroup8(raw["server.integration"]), - credentials: adaptGroup9(raw["server.credential"]), - permissions: adaptGroup10(raw["server.permission"]), - files: adaptGroup11(raw["server.fs"]), - commands: adaptGroup12(raw["server.command"]), - skills: adaptGroup13(raw["server.skill"]), - events: adaptGroup14(raw["server.event"]), - ptys: adaptGroup15(raw["server.pty"]), - questions: adaptGroup16(raw["server.question"]), - references: adaptGroup17(raw["server.reference"]), - projectCopies: adaptGroup18(raw["server.projectCopy"]), + "server.clients": adaptGroup1(raw["server.clients"]), + location: adaptGroup2(raw["server.location"]), + agents: adaptGroup3(raw["server.agent"]), + sessions: adaptGroup4(raw["server.session"]), + messages: adaptGroup5(raw["server.message"]), + models: adaptGroup6(raw["server.model"]), + providers: adaptGroup7(raw["server.provider"]), + "server.variant": adaptGroup8(raw["server.variant"]), + integrations: adaptGroup9(raw["server.integration"]), + credentials: adaptGroup10(raw["server.credential"]), + privacy: adaptGroup11(raw["server.privacy"]), + permissions: adaptGroup12(raw["server.permission"]), + files: adaptGroup13(raw["server.fs"]), + commands: adaptGroup14(raw["server.command"]), + skills: adaptGroup15(raw["server.skill"]), + events: adaptGroup16(raw["server.event"]), + ptys: adaptGroup17(raw["server.pty"]), + questions: adaptGroup18(raw["server.question"]), + browserRequests: adaptGroup19(raw["server.browser"]), + references: adaptGroup20(raw["server.reference"]), + projectCopies: adaptGroup21(raw["server.projectCopy"]), + "server.chat": adaptGroup22(raw["server.chat"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 521b25e0b4..3ab8629ebd 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -1,5 +1,12 @@ import type { HealthGetOutput, + ServerClientsListOutput, + ServerClientsAttachInput, + ServerClientsAttachOutput, + ServerClientsHeartbeatInput, + ServerClientsHeartbeatOutput, + ServerClientsDetachInput, + ServerClientsDetachOutput, LocationGetInput, LocationGetOutput, AgentsListInput, @@ -67,6 +74,10 @@ import type { CredentialsUpdateOutput, CredentialsRemoveInput, CredentialsRemoveOutput, + PrivacyGetInput, + PrivacyGetOutput, + PrivacySetInput, + PrivacySetOutput, PermissionsListRequestsInput, PermissionsListRequestsOutput, PermissionsListSavedInput, @@ -108,6 +119,14 @@ import type { QuestionsReplyOutput, QuestionsRejectInput, QuestionsRejectOutput, + BrowserRequestsListRequestsInput, + BrowserRequestsListRequestsOutput, + BrowserRequestsListInput, + BrowserRequestsListOutput, + BrowserRequestsReplyInput, + BrowserRequestsReplyOutput, + BrowserRequestsRefuseInput, + BrowserRequestsRefuseOutput, ReferencesListInput, ReferencesListOutput, ProjectCopiesCreateInput, @@ -116,6 +135,8 @@ import type { ProjectCopiesRemoveOutput, ProjectCopiesRefreshInput, ProjectCopiesRefreshOutput, + ServerChatCompletionsInput, + ServerChatCompletionsOutput, } from "./types" import { ClientError } from "./client-error" @@ -258,6 +279,47 @@ export function make(options: ClientOptions) { requestOptions, ), }, + "server.clients": { + list: (requestOptions?: RequestOptions) => + request( + { method: "GET", path: `/api/clients`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ), + attach: (input: ServerClientsAttachInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/clients`, + body: { app: input["app"], pid: input["pid"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + heartbeat: (input: ServerClientsHeartbeatInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/clients/${encodeURIComponent(input.clientID)}/heartbeat`, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), + detach: (input: ServerClientsDetachInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/clients/${encodeURIComponent(input.clientID)}`, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, location: { get: (input?: LocationGetInput, requestOptions?: RequestOptions) => request( @@ -693,6 +755,33 @@ export function make(options: ClientOptions) { requestOptions, ), }, + privacy: { + get: (input?: PrivacyGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/privacy`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + set: (input: PrivacySetInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/privacy`, + query: { location: input["location"] }, + body: { mode: input["mode"], model: input["model"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, permissions: { listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) => request( @@ -963,6 +1052,55 @@ export function make(options: ClientOptions) { requestOptions, ), }, + browserRequests: { + listRequests: (input?: BrowserRequestsListRequestsInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/browser/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + list: (input: BrowserRequestsListInput, requestOptions?: RequestOptions) => + request<{ readonly data: BrowserRequestsListOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/browser`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + reply: (input: BrowserRequestsReplyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/browser/${encodeURIComponent(input.requestID)}/reply`, + body: { value: input["value"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + refuse: (input: BrowserRequestsRefuseInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/browser/${encodeURIComponent(input.requestID)}/refuse`, + body: { reason: input["reason"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + }, references: { list: (input?: ReferencesListInput, requestOptions?: RequestOptions) => request( @@ -1017,6 +1155,36 @@ export function make(options: ClientOptions) { requestOptions, ), }, + "server.chat": { + completions: (input: ServerChatCompletionsInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/v1/chat/completions`, + body: { + model: input["model"], + messages: input["messages"], + tools: input["tools"], + tool_choice: input["tool_choice"], + stream: input["stream"], + max_tokens: input["max_tokens"], + max_completion_tokens: input["max_completion_tokens"], + temperature: input["temperature"], + top_p: input["top_p"], + stop: input["stop"], + seed: input["seed"], + frequency_penalty: input["frequency_penalty"], + presence_penalty: input["presence_penalty"], + reasoning_effort: input["reasoning_effort"], + user: input["user"], + }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 948d394efd..508ffa3dc0 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -21,6 +21,14 @@ export type InvalidRequestError = { export const isInvalidRequestError = (value: unknown): value is InvalidRequestError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError" +export type ClientNotFoundError = { + readonly _tag: "ClientNotFoundError" + readonly clientID: string + readonly message: string +} +export const isClientNotFoundError = (value: unknown): value is ClientNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ClientNotFoundError" + export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string } export const isInvalidCursorError = (value: unknown): value is InvalidCursorError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError" @@ -94,6 +102,14 @@ export type QuestionNotFoundError = { export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError" +export type BrowserRequestNotFoundError = { + readonly _tag: "BrowserRequestNotFoundError" + readonly requestID: string + readonly message: string +} +export const isBrowserRequestNotFoundError = (value: unknown): value is BrowserRequestNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "BrowserRequestNotFoundError" + export type ProjectCopyError = { readonly name: "ProjectCopyError" readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined } @@ -103,6 +119,46 @@ export const isProjectCopyError = (value: unknown): value is ProjectCopyError => export type HealthGetOutput = { readonly healthy: true } +export type ServerClientsListOutput = { + readonly clients: ReadonlyArray<{ + readonly id: string + readonly app: string + readonly pid?: number | undefined + readonly attachedAt: number + readonly lastSeen: number + }> + readonly ttlMs: number + readonly idleForMs?: number | undefined + readonly idleExitAfterMs?: number | undefined +} + +export type ServerClientsAttachInput = { + readonly app: { readonly app: string; readonly pid?: number | undefined }["app"] + readonly pid?: { readonly app: string; readonly pid?: number | undefined }["pid"] +} + +export type ServerClientsAttachOutput = { + readonly id: string + readonly app: string + readonly pid?: number | undefined + readonly attachedAt: number + readonly lastSeen: number +} + +export type ServerClientsHeartbeatInput = { readonly clientID: { readonly clientID: string }["clientID"] } + +export type ServerClientsHeartbeatOutput = { + readonly id: string + readonly app: string + readonly pid?: number | undefined + readonly attachedAt: number + readonly lastSeen: number +} + +export type ServerClientsDetachInput = { readonly clientID: { readonly clientID: string }["clientID"] } + +export type ServerClientsDetachOutput = void + export type LocationGetInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2364,6 +2420,24 @@ export type CredentialsRemoveInput = { export type CredentialsRemoveOutput = void +export type PrivacyGetInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type PrivacyGetOutput = { readonly mode: "off" | "regex" | "model"; readonly model?: string | undefined } + +export type PrivacySetInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly mode: { readonly mode: "off" | "regex" | "model"; readonly model?: string | undefined }["mode"] + readonly model?: { readonly mode: "off" | "regex" | "model"; readonly model?: string | undefined }["model"] +} + +export type PrivacySetOutput = { readonly mode: "off" | "regex" | "model"; readonly model?: string | undefined } + export type PermissionsListRequestsInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2609,6 +2683,8 @@ export type SkillsListOutput = { readonly name: string readonly description?: string readonly slash?: boolean + readonly icon?: string + readonly autoInject?: { readonly keywords?: ReadonlyArray; readonly url?: ReadonlyArray } readonly location: string readonly content: string }> @@ -2821,6 +2897,83 @@ export type QuestionsRejectInput = { export type QuestionsRejectOutput = void +export type BrowserRequestsListRequestsInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type BrowserRequestsListRequestsOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly command: + | { readonly action: "page.url" } + | { readonly action: "page.text" } + | { + readonly action: "page.query" + readonly selector: string + readonly limit?: number | "Infinity" | "-Infinity" | "NaN" + } + | { readonly action: "page.click"; readonly selector: string } + | { readonly action: "page.type"; readonly selector: string; readonly text: string; readonly submit?: boolean } + | { readonly action: "page.navigate"; readonly url: string } + }> +} + +export type BrowserRequestsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type BrowserRequestsListOutput = { + readonly data: ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly command: + | { readonly action: "page.url" } + | { readonly action: "page.text" } + | { + readonly action: "page.query" + readonly selector: string + readonly limit?: number | "Infinity" | "-Infinity" | "NaN" + } + | { readonly action: "page.click"; readonly selector: string } + | { readonly action: "page.type"; readonly selector: string; readonly text: string; readonly submit?: boolean } + | { readonly action: "page.navigate"; readonly url: string } + }> +}["data"] + +export type BrowserRequestsReplyInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] + readonly value: { + readonly value: + | { readonly type: "string"; readonly value: string } + | { + readonly type: "nodes" + readonly value: ReadonlyArray<{ + readonly selector: string + readonly text: string + readonly attributes: { readonly [x: string]: string } + }> + } + | { readonly type: "void" } + }["value"] +} + +export type BrowserRequestsReplyOutput = void + +export type BrowserRequestsRefuseInput = { + readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] + readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] + readonly reason: { readonly reason: string }["reason"] +} + +export type BrowserRequestsRefuseOutput = void + export type ReferencesListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2881,3 +3034,677 @@ export type ProjectCopiesRefreshInput = { } export type ProjectCopiesRefreshOutput = void + +export type ServerChatCompletionsInput = { + readonly model: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["model"] + readonly messages: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["messages"] + readonly tools?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["tools"] + readonly tool_choice?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["tool_choice"] + readonly stream?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["stream"] + readonly max_tokens?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["max_tokens"] + readonly max_completion_tokens?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["max_completion_tokens"] + readonly temperature?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["temperature"] + readonly top_p?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["top_p"] + readonly stop?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["stop"] + readonly seed?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["seed"] + readonly frequency_penalty?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["frequency_penalty"] + readonly presence_penalty?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["presence_penalty"] + readonly reasoning_effort?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["reasoning_effort"] + readonly user?: { + readonly model: string + readonly messages: ReadonlyArray<{ + readonly role: "system" | "developer" | "user" | "assistant" | "tool" + readonly content?: string | null | ReadonlyArray<{ readonly [x: string]: unknown }> | undefined + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly tool_call_id?: string | undefined + readonly name?: string | undefined + }> + readonly tools?: + | ReadonlyArray<{ + readonly type: "function" + readonly function: { + readonly name: string + readonly description?: string | undefined + readonly parameters?: { readonly [x: string]: unknown } | undefined + } + }> + | undefined + readonly tool_choice?: + | "auto" + | "none" + | "required" + | { readonly type: "function"; readonly function: { readonly name: string } } + | undefined + readonly stream?: boolean | undefined + readonly max_tokens?: number | undefined + readonly max_completion_tokens?: number | undefined + readonly temperature?: number | undefined + readonly top_p?: number | undefined + readonly stop?: string | ReadonlyArray | undefined + readonly seed?: number | undefined + readonly frequency_penalty?: number | undefined + readonly presence_penalty?: number | undefined + readonly reasoning_effort?: string | undefined + readonly user?: string | undefined + }["user"] +} + +export type ServerChatCompletionsOutput = { + readonly id: string + readonly object: "chat.completion" + readonly created: number + readonly model: string + readonly choices: ReadonlyArray<{ + readonly index: number + readonly message: { + readonly role: "assistant" + readonly content: string | null + readonly tool_calls?: + | ReadonlyArray<{ + readonly id: string + readonly type: "function" + readonly function: { readonly name: string; readonly arguments: string } + }> + | undefined + readonly reasoning_content?: string | undefined + } + readonly finish_reason: string | null + }> + readonly usage?: + | { readonly prompt_tokens: number; readonly completion_tokens: number; readonly total_tokens: number } + | undefined +} diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 14782d7c8a..1cd36e56c6 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,5 +1,5 @@ import { Effect, Schema } from "effect" -import { executeWithLimits } from "./interpreter/runtime.js" +import { assertValidGlobals, executeWithLimits } from "./interpreter/runtime.js" import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js" import type { Definition } from "./tool.js" @@ -38,6 +38,14 @@ export type ExecuteOptions = {}> = { code: string /** Explicit tool tree exposed to the program as `tools`. */ tools?: Tools & ToolTree> + /** + * Top-level tool namespaces ALSO bound as bare globals, so a skill document can be + * written as `await page.text()` rather than `await tools.page.text()`. Each name must + * be a namespace at the top level of `tools` and must not shadow a builtin global. + * A global is an alias for the same tool path: one implementation, one authorization + * point. Code Mode stays host-neutral - it never knows what `page` means. + */ + globals?: ReadonlyArray /** Per-execution overrides for the default resource limits. */ limits?: ExecutionLimits /** Observes decoded tool input immediately before tool execution. */ @@ -139,6 +147,7 @@ export const execute = >( ): Effect.Effect> => { const tools = (options.tools ?? {}) as HostTools> ToolRuntime.assertValidTools(tools) + assertValidGlobals(tools, options.globals ?? []) return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) } @@ -148,8 +157,10 @@ export const make = = {}>( ): Runtime> => { const tools = (options.tools ?? {}) as HostTools> ToolRuntime.assertValidTools(tools) + const globals = options.globals ?? [] + assertValidGlobals(tools, globals) const limits = resolveExecutionLimits(options.limits) - const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget) + const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget, globals) return { catalog: () => prepared.catalog, diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 093f577765..573cdc493b 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -13,6 +13,8 @@ import { type Services, } from "../tool-runtime.js" import { ToolError } from "../tool-error.js" +import { isDefinition as isToolDefinition } from "../tool.js" +import { identifierSegment } from "../tool-schema.js" import type { DataValue, Diagnostic, @@ -599,6 +601,85 @@ const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { + const globalScope = new Map() + globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) + globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) + globalScope.set("undefined", { mutable: false, value: undefined }) + globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) + globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) + globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") }) + globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") }) + globalScope.set("String", { mutable: false, value: new CoercionFunction("String") }) + globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") }) + globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) + globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") }) + globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) + globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) + globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") }) + globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") }) + globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") }) + globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") }) + globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") }) + globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") }) + globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") }) + globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") }) + globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") }) + globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") }) + // Error constructors are real values, so `x instanceof Error` works and `Error("msg")` + // (with or without `new`) constructs a branded { name, message } error object. + for (const name of errorConstructors) { + globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) + } + // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data + // boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. + globalScope.set("NaN", { mutable: false, value: NaN }) + globalScope.set("Infinity", { mutable: false, value: Infinity }) + return globalScope +} + +/** + * Every name the interpreter seeds into the builtin global scope. A host global + * (see `ExecuteOptions.globals`) may not shadow one of these. + */ +export const BUILTIN_GLOBAL_NAMES: ReadonlySet = new Set(seedBuiltinGlobals().keys()) + +/** + * Validates the names a host asks to bind as bare globals. A global is an alias for a + * top-level namespace of the tool tree, so it must name one, must not shadow a builtin + * global, and must be a plain identifier (a bare binding cannot be written in bracket + * notation). A duplicate is refused rather than deduplicated: a repeated name in a host's + * list is a configuration mistake worth surfacing. + * + * Lives here, beside `seedBuiltinGlobals`, so the reserved-name check reads the same set + * the interpreter actually seeds. + */ +export const assertValidGlobals = (tools: HostTools, globals: ReadonlyArray): void => { + const seen = new Set() + for (const name of globals) { + if (!identifierSegment.test(name)) { + throw new Error(`Global '${name}' is not a plain identifier and cannot be bound as a global.`) + } + if (BUILTIN_GLOBAL_NAMES.has(name)) { + throw new Error(`Global '${name}' collides with a builtin global.`) + } + if (seen.has(name)) throw new Error(`Global '${name}' is listed more than once.`) + seen.add(name) + if (!Object.hasOwn(tools, name)) { + throw new Error(`Global '${name}' is not a top-level namespace of the tool tree.`) + } + const namespace = tools[name] + if (typeof namespace === "function" || isToolDefinition(namespace)) { + throw new Error(`Global '${name}' must be a namespace of tools, not a tool itself.`) + } + } +} + class Interpreter { private scopes: Array> private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect @@ -618,46 +699,28 @@ class Interpreter { invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, logs: Array = [], + /** + * Host tool namespaces additionally bound at the top level, so a program writes + * `page.text()` instead of `tools.page.text()`. Each name resolves to the same tool + * path, so there is one implementation and one authorization point, not two. + */ + hostGlobals: ReadonlyArray = [], ) { - const globalScope = new Map() + const globalScope = seedBuiltinGlobals() this.scopes = [globalScope] this.invokeTool = invokeTool this.toolKeys = toolKeys this.logs = logs this.lastValue = undefined this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) - globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) - globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) - globalScope.set("undefined", { mutable: false, value: undefined }) - globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) - globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) - globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") }) - globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") }) - globalScope.set("String", { mutable: false, value: new CoercionFunction("String") }) - globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") }) - globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) - globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") }) - globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) - globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) - globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") }) - globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") }) - globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") }) - globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") }) - globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") }) - globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") }) - globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") }) - globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") }) - globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") }) - globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") }) - // Error constructors are real values, so `x instanceof Error` works and `Error("msg")` - // (with or without `new`) constructs a branded { name, message } error object. - for (const name of errorConstructors) { - globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) - } - // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data - // boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. - globalScope.set("NaN", { mutable: false, value: NaN }) - globalScope.set("Infinity", { mutable: false, value: Infinity }) + for (const name of hostGlobals) { + // Validated by ToolRuntime.assertValidGlobals before construction; a collision + // here would silently replace a builtin, so refuse rather than overwrite. + if (BUILTIN_GLOBAL_NAMES.has(name)) { + throw new Error(`Host global '${name}' collides with a builtin global.`) + } + globalScope.set(name, { mutable: false, value: new ToolReference([name]) }) + } } run(program: ProgramNode): Effect.Effect { @@ -3365,7 +3428,7 @@ export const executeWithLimits = >( const operation = Effect.gen(function* () { const program = parseProgram(options.code) - const interpreter = new Interpreter>(tools.invoke, tools.keys, logs) + const interpreter = new Interpreter>(tools.invoke, tools.keys, logs, options.globals ?? []) const value = yield* interpreter.run(program) const result = copyOut(copyIn(value, "Execution result"), true) as DataValue return { diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index f4ccc61d4c..a5f70823a0 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -481,6 +481,24 @@ export const assertValidTools = (tools: HostTools): void => { } } +/** + * Renders the host-global aliases. Each listed namespace is reachable both as a bare + * identifier and under `tools`, so a skill document can open with `await page.text()` + * without the model having to be told that `page` is "really" a tool namespace. + */ +const globalsSection = (globals: ReadonlyArray): Array => { + if (globals.length === 0) return [] + const ordered = [...globals].sort((left, right) => left.localeCompare(right)) + return [ + "", + "## Domain globals", + "", + `These namespaces are also bound as bare globals: ${ordered.map((name) => `\`${name}\``).join(", ")}.`, + "`.(input)` and `tools..(input)` are the same call; prefer the bare form.", + "A global's tools are listed under its namespace in the catalog below, like any other tool.", + ] +} + /** * Budgeted catalog: every namespace is always listed with its tool count; full call * signatures are inlined against the `catalogBudget` (estimated tokens, @@ -492,7 +510,12 @@ export const assertValidTools = (tools: HostTools): void => { * namespace. Namespace stub lines are never budgeted: every namespace appears with its * tool count even at budget 0. */ -export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { +export const prepare = ( + tools: HostTools, + catalogBudget = defaultCatalogBudget, + /** Namespaces also bound as bare globals; rendered so the model knows it may write `page.text()`. */ + globals: ReadonlyArray = [], +): DiscoveryPlan => { if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") } @@ -639,7 +662,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu } } - const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection] + const lines = [...intro, ...workflow, ...rules, ...globalsSection(globals), ...language, ...toolSection] return { catalog: described, instructions: lines.join("\n"), diff --git a/packages/codemode/test/globals.test.ts b/packages/codemode/test/globals.test.ts new file mode 100644 index 0000000000..c27d9bc1a6 --- /dev/null +++ b/packages/codemode/test/globals.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool } from "../src/index.js" + +const text = Tool.make({ + description: "Visible text of the page", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.succeed("hello from the page"), +}) + +const click = Tool.make({ + description: "Click the first node matching a selector", + input: Schema.Struct({ selector: Schema.String }), + output: Schema.Boolean, + run: (input) => Effect.succeed(input.selector === "button"), +}) + +const page = { text, click } + +const run = (code: string, globals: ReadonlyArray = ["page"]) => + Effect.runPromise(CodeMode.make({ tools: { page }, globals }).execute(code)) + +describe("CodeMode host globals", () => { + test("a bare global resolves to its tool namespace", async () => { + const result = await run("return await page.text({})") + expect(result).toMatchObject({ ok: true, value: "hello from the page" }) + }) + + test("the bare form and the tools form are the same call", async () => { + const result = await run( + 'return [await page.click({ selector: "button" }), await tools.page.click({ selector: "a" })]', + ) + expect(result).toMatchObject({ ok: true, value: [true, false] }) + expect(result.toolCalls.map((call) => call.name)).toStrictEqual(["page.click", "page.click"]) + }) + + test("a global is enumerable like any namespace", async () => { + const result = await run("return Object.keys(page).toSorted()") + expect(result).toMatchObject({ ok: true, value: ["click", "text"] }) + }) + + test("a top-level declaration still shadows a global, as in a JS module", async () => { + const result = await run('const page = "shadowed"; return page') + expect(result).toMatchObject({ ok: true, value: "shadowed" }) + }) + + test("an unlisted namespace is not bound as a global", async () => { + const result = await Effect.runPromise(CodeMode.make({ tools: { page } }).execute("return await page.text({})")) + expect(result.ok).toBe(false) + expect(result.ok ? "" : result.error.message).toContain("page") + }) + + test("the instructions name the globals and the equivalence", async () => { + const instructions = CodeMode.make({ tools: { page }, globals: ["page"] }).instructions() + expect(instructions).toContain("## Domain globals") + expect(instructions).toContain("`page`") + expect(instructions).toContain("`tools..(input)` are the same call") + }) + + test("no globals section is rendered when no globals are declared", () => { + expect(CodeMode.make({ tools: { page } }).instructions()).not.toContain("## Domain globals") + }) + + describe("refuses a global a host cannot honestly bind", () => { + const cases: ReadonlyArray, string]> = [ + ["a name that is not in the tool tree", ["screen"], "not a top-level namespace"], + ["a builtin global", ["Math"], "collides with a builtin global"], + ["the tools binding itself", ["tools"], "collides with a builtin global"], + ["a duplicate", ["page", "page"], "listed more than once"], + ["a non-identifier", ["page-object"], "not a plain identifier"], + ] + for (const [name, globals, message] of cases) { + test(name, () => { + expect(() => CodeMode.make({ tools: { page }, globals })).toThrow(message) + }) + } + + test("a tool rather than a namespace", () => { + expect(() => CodeMode.make({ tools: { text }, globals: ["text"] })).toThrow("not a tool itself") + }) + }) +}) diff --git a/packages/core/src/browser-request.ts b/packages/core/src/browser-request.ts new file mode 100644 index 0000000000..1193b2324a --- /dev/null +++ b/packages/core/src/browser-request.ts @@ -0,0 +1,218 @@ +export * as BrowserRequestV1 from "./browser-request" + +import { makeLocationNode } from "./effect/app-node" +import { Context, Deferred, Duration, Effect, Layer, Schema } from "effect" +import { BrowserRequest } from "@redrob-code/schema/browser-request" +import { EventV2 } from "./event" +import { SessionSchema } from "./session/schema" + +export const ID = BrowserRequest.ID +export type ID = typeof ID.Type + +export const Command = BrowserRequest.Command +export type Command = typeof Command.Type + +export const Request = BrowserRequest.Request +export type Request = typeof Request.Type + +export const Value = BrowserRequest.Value +export type Value = typeof Value.Type + +export const Node = BrowserRequest.Node +export type Node = typeof Node.Type + +export const Event = BrowserRequest.Event + +/** + * The deadline a pending browser request waits for a client before giving up. + * + * It exists because nothing tells the engine whether a client is listening: the event + * stream is a publish/subscribe fan-out with no subscriber count, so "no browser attached" + * and "the browser is thinking" look identical from here. Waiting forever is the one + * behaviour that is certainly wrong — a skill calling `page.text()` with no browser would + * hang its session rather than fail. + * + * Long enough that a real click and its navigation settle; short enough that an unattended + * engine answers in seconds. + */ +export const DEADLINE = Duration.seconds(20) + +/** + * No client settled the request. Carries the action by name, because which call went + * unanswered is the whole diagnostic: `page.navigate` timing out on a slow site and + * `page.url` timing out with nothing attached are different problems. + */ +export class UnansweredError extends Schema.TaggedErrorClass()("BrowserRequestV1.UnansweredError", { + action: Schema.String, +}) { + override get message() { + return `no browser client answered ${this.action} within ${Duration.toSeconds(DEADLINE)}s; the browser must be running with the Redrob extension attached to this engine` + } +} + +/** The client took the request and said it could not do it. Its sentence is carried through. */ +export class RefusedError extends Schema.TaggedErrorClass()("BrowserRequestV1.RefusedError", { + action: Schema.String, + reason: Schema.String, +}) { + override get message() { + return `the browser refused ${this.action}: ${this.reason}` + } +} + +/** + * The client answered with a value the action cannot produce. + * + * Separate from `RefusedError` on purpose. A refusal is the browser reporting about the + * page; this is the client disagreeing with the protocol, and collapsing the two would let + * a wrong-shaped answer read as a fact about the page. + */ +export class MalformedError extends Schema.TaggedErrorClass()("BrowserRequestV1.MalformedError", { + action: Schema.String, + expected: Schema.String, + received: Schema.String, +}) { + override get message() { + return `the browser answered ${this.action} with a ${this.received} value where ${this.expected} was required` + } +} + +export class NotFoundError extends Schema.TaggedErrorClass()("BrowserRequestV1.NotFoundError", { + requestID: ID, +}) {} + +export type AskError = UnansweredError | RefusedError + +export interface AskInput { + readonly sessionID: SessionSchema.ID + readonly command: Command +} + +export interface ReplyInput { + readonly requestID: ID + readonly value: Value +} + +export interface RefuseInput { + readonly requestID: ID + readonly reason: string +} + +export interface Interface { + /** Publishes one browser action and waits for a client to settle it. */ + readonly ask: (input: AskInput) => Effect.Effect + readonly reply: (input: ReplyInput) => Effect.Effect + readonly refuse: (input: RefuseInput) => Effect.Effect + readonly list: () => Effect.Effect> +} + +export class Service extends Context.Service()("@redrob/v1/BrowserRequest") {} + +interface Pending { + readonly request: Request + readonly deferred: Deferred.Deferred +} + +/** + * Location-owned pending browser requests, materialized once per embedded Location so a + * reply cannot settle another Location's request — the same ownership rule `QuestionV2` + * documents and for the same reason. + */ +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const pending = new Map() + + yield* Effect.addFinalizer(() => + Effect.forEach( + pending.values(), + (item) => + Deferred.fail( + item.deferred, + new RefusedError({ action: item.request.command.action, reason: "the engine shut down" }), + ), + { discard: true }, + ).pipe( + Effect.ensuring( + Effect.sync(() => { + pending.clear() + }), + ), + ), + ) + + const ask = Effect.fn("BrowserRequestV1.ask")((input: AskInput) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const id = ID.ascending() + const deferred = yield* Deferred.make() + const request: Request = { id, sessionID: input.sessionID, command: input.command } + pending.set(id, { request, deferred }) + return yield* events.publish(Event.Asked, request).pipe( + Effect.andThen( + restore(Deferred.await(deferred)).pipe( + // `timeoutOrElse` with a failing fallback rather than a plain `timeout`: + // the caller needs to be told that nobody answered, by action name, not + // handed a generic timeout exception or an empty success. + Effect.timeoutOrElse({ + duration: DEADLINE, + orElse: () => Effect.fail(new UnansweredError({ action: input.command.action })), + }), + ), + ), + Effect.ensuring( + Effect.sync(() => { + pending.delete(id) + }), + ), + ) + }), + ), + ) + + const reply = Effect.fn("BrowserRequestV1.reply")((input: ReplyInput) => + Effect.uninterruptible( + Effect.gen(function* () { + const existing = pending.get(input.requestID) + if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) + yield* events.publish(Event.Answered, { + sessionID: existing.request.sessionID, + requestID: existing.request.id, + }) + yield* Deferred.succeed(existing.deferred, input.value) + pending.delete(input.requestID) + }), + ), + ) + + const refuse = Effect.fn("BrowserRequestV1.refuse")((input: RefuseInput) => + Effect.uninterruptible( + Effect.gen(function* () { + const existing = pending.get(input.requestID) + if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) + yield* events.publish(Event.Refused, { + sessionID: existing.request.sessionID, + requestID: existing.request.id, + reason: input.reason, + }) + yield* Deferred.fail( + existing.deferred, + new RefusedError({ action: existing.request.command.action, reason: input.reason }), + ) + pending.delete(input.requestID) + }), + ), + ) + + const list = Effect.fn("BrowserRequestV1.list")(function* () { + return Array.from(pending.values(), (item) => item.request) + }) + + return Service.of({ ask, reply, refuse, list }) + }), +) + +export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: () => [EventV2.node] }) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 5227874d53..c6e80cb981 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -13,6 +13,7 @@ import { AbsolutePath } from "./schema" import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" +import { ConfigPrivacy } from "./config/privacy" import { ConfigCommand } from "./config/command" import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" @@ -87,6 +88,9 @@ export class Info extends Schema.Class("Config.Info")({ compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({ description: "Conversation compaction behavior", }), + privacy: ConfigPrivacy.Info.pipe(Schema.optional).annotate({ + description: "What personal data may leave this machine inside a model request", + }), skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ description: "Additional paths or URLs to discover skills from", }), diff --git a/packages/core/src/config/plugin/local-gpu-probe.ts b/packages/core/src/config/plugin/local-gpu-probe.ts new file mode 100644 index 0000000000..024c919080 --- /dev/null +++ b/packages/core/src/config/plugin/local-gpu-probe.ts @@ -0,0 +1,98 @@ +export * as LocalGpuProbe from "./local-gpu-probe" + +/** + * Reading the GPU, where the platform lets us. + * + * Split from `local-model-fit` on purpose. The fit judgement is pure and testable with no + * machine at all; this part shells out, and what it can learn differs per platform and per + * driver. Keeping them apart means the verdict logic is never untestable because the probe + * is. + * + * THE ONLY RULE THAT MATTERS HERE: a probe that cannot see a GPU returns `undefined`, which + * the fit module reads as NOT MEASURED. It never returns "no GPU". Telling a user with a + * discrete card that their machine has none is a false statement about their own computer, + * and it is the statement they would push back on hardest and be right about. + */ + +import { Effect } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { AppProcess, requireSuccess } from "../../process" + +/** What a probe can learn. `vramBytes` is absent when the platform reports a name but no size. */ +export type Gpu = { readonly name: string; readonly vramBytes?: number | undefined } + +/** Short, because this runs on a path a user is waiting on and a wedged driver tool must not hold it. */ +const PROBE_TIMEOUT = "3 seconds" + +const MiB = 1024 * 1024 + +/** + * The per-platform command and how to read it. Exported so the parsers are testable against + * recorded tool output without a GPU, a driver, or that platform. + * + * Each is a listing command with machine-readable output, chosen over the prettier + * human-facing variants so the parse does not depend on column widths. + */ +export const PROBES: ReadonlyArray<{ + readonly platforms: ReadonlyArray + readonly command: string + readonly args: ReadonlyArray + readonly parse: (stdout: string) => Gpu | undefined +}> = [ + { + // NVIDIA, any platform it is installed on. CSV without units keeps the parse trivial. + platforms: ["linux", "win32", "darwin"], + command: "nvidia-smi", + args: ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], + parse: (stdout) => { + const line = stdout.split("\n").find((candidate) => candidate.trim().length > 0) + if (line === undefined) return undefined + const [name, memory] = line.split(",").map((field) => field.trim()) + if (name === undefined || name.length === 0) return undefined + const megabytes = Number(memory) + return { name, vramBytes: Number.isFinite(megabytes) && megabytes > 0 ? megabytes * MiB : undefined } + }, + }, + { + // Apple silicon: the GPU shares system memory, so there is a name and no separate VRAM. + // Reporting a number here would be wrong in the direction that matters -- it would be + // counted twice against the same bytes the memory check already counted. + platforms: ["darwin"], + command: "sysctl", + args: ["-n", "machdep.cpu.brand_string"], + parse: (stdout) => { + const brand = stdout.trim() + if (!brand.startsWith("Apple ")) return undefined + return { name: `${brand} (unified memory)`, vramBytes: undefined } + }, + }, +] + +/** + * Try each probe for this platform, first answer wins, every failure is silent. + * + * Silent is right: on a machine with no discrete GPU, `nvidia-smi` not existing is the + * normal state of the world, not a condition to report. The caller gets `undefined` and + * says "not measured", which is the true statement. + * + * Runs through `AppProcess.run`, the service the rest of the engine shells out with, rather + * than driving the spawner directly -- output collection, truncation and timeout handling + * already live there and a second copy of them would drift. + */ +export const probe = Effect.fn("LocalGpuProbe.probe")(function* (platform: string) { + const process = yield* AppProcess.Service + for (const candidate of PROBES) { + if (!candidate.platforms.includes(platform)) continue + const result = yield* Effect.gen(function* () { + const run = yield* process + .run(ChildProcess.make(candidate.command, [...candidate.args], { stdin: "ignore", extendEnv: true })) + .pipe(Effect.flatMap(requireSuccess)) + return candidate.parse(run.stdout.toString("utf8")) + }).pipe( + Effect.timeout(PROBE_TIMEOUT), + Effect.catchCause(() => Effect.succeed(undefined)), + ) + if (result !== undefined) return result + } + return undefined +}) diff --git a/packages/core/src/config/plugin/local-model-fit.ts b/packages/core/src/config/plugin/local-model-fit.ts new file mode 100644 index 0000000000..3b8f40cae6 --- /dev/null +++ b/packages/core/src/config/plugin/local-model-fit.ts @@ -0,0 +1,143 @@ +export * as LocalModelFit from "./local-model-fit" + +/** + * Whether this machine can run a local model, and if not, exactly what is short. + * + * PA-8's decisive half. Running a model locally is not a preference, it is a hardware + * question, and the only dishonest answer is a confident one. So this module is built around + * three rules: + * + * 1. It reports MEASURED facts, never estimates dressed as facts. Total and available + * memory and the CPU come from the OS. A GPU is read where the platform exposes it and + * reported as UNKNOWN where it does not -- `unknown` is a third answer, distinct from + * "no GPU", because a machine with a GPU we cannot see must not be told it has none. + * 2. It never invents a model's size. The required figure comes from whoever is offering + * the model -- the runtime's own registry -- and is passed in. A fit check against a + * size this module guessed would be a guess wearing a verdict's clothes. + * 3. A refusal names the constraint and the shortfall. "Your machine cannot run this" is + * not actionable; "needs 8.0 GiB of available memory, this machine has 3.2 GiB free of + * 15.5 GiB total" tells the user whether closing something would fix it. + * + * There is deliberately NO cloud fallback here, silently or otherwise. A user who asked for + * a local model asked for the data to stay on the machine; quietly answering from a hosted + * model would defeat the only reason to want this, and would do it invisibly. + */ + +import * as os from "node:os" + +/** What the machine is, as measured. Every field is read, none is inferred. */ +export type Hardware = { + readonly platform: string + readonly arch: string + readonly cores: number + readonly totalBytes: number + readonly availableBytes: number + /** + * The GPU, when this platform lets us see it. + * + * `undefined` means NOT MEASURED, not absent. The difference matters: a refusal that + * claims a machine has no GPU when we simply could not look is a false statement about + * the user's own computer. + */ + readonly gpu?: { readonly name: string; readonly vramBytes?: number | undefined } | undefined +} + +/** The verdict. A refusal always carries the numbers that produced it. */ +export type Fit = + | { readonly kind: "fits"; readonly hardware: Hardware } + | { + readonly kind: "short" + readonly hardware: Hardware + /** Which constraint failed, for a caller that wants to branch rather than print. */ + readonly constraint: "memory" | "arch" + readonly requiredBytes: number + readonly shortfallBytes: number + readonly reason: string + } + +/** + * Headroom beyond the weights themselves. + * + * A model's file size is not its running size: the runtime holds the KV cache, the context + * window and its own working set alongside the weights. 20% with a 1 GiB floor is a + * deliberately rough allowance, and it is named here rather than buried so that a caller + * reading a refusal can see it is part of the requirement. + * + * Rough is correct for this. The alternative is a precise-looking formula over quantisation, + * context length and runtime, which would be wrong in a way that reads as authoritative. + */ +export const HEADROOM_FRACTION = 0.2 +export const HEADROOM_FLOOR_BYTES = 1024 ** 3 + +export const requirementFor = (modelBytes: number): number => + modelBytes + Math.max(Math.round(modelBytes * HEADROOM_FRACTION), HEADROOM_FLOOR_BYTES) + +const GiB = 1024 ** 3 +const gib = (bytes: number): string => `${(bytes / GiB).toFixed(1)} GiB` + +/** + * Architectures a local runtime has builds for. + * + * Checked because the failure is otherwise a confusing download: the weights arrive, the + * runtime will not start, and nothing in that sequence mentions the CPU. + */ +const SUPPORTED_ARCH = new Set(["x64", "arm64"]) + +/** + * Measure the machine. + * + * `availableBytes` uses `os.freemem()`, which on Linux counts reclaimable page cache as used + * and therefore UNDERSTATES what a process could actually get. That direction is the safe + * one: it can refuse a model that would in fact have squeezed in, and it will not promise + * one that would have been killed mid-generation. The opposite error loses the user's work. + */ +export const measure = (gpu?: Hardware["gpu"]): Hardware => ({ + platform: os.platform(), + arch: os.arch(), + cores: os.cpus().length, + totalBytes: os.totalmem(), + availableBytes: os.freemem(), + gpu, +}) + +/** + * Can this machine run a model of this size? + * + * Memory is checked against AVAILABLE rather than total, because a model that needs more + * than is free today does not run today, however large the machine is. The refusal reports + * both numbers so the user can tell "buy a bigger machine" from "close your other windows". + */ +export const fit = (input: { readonly modelBytes: number; readonly hardware: Hardware }): Fit => { + const { hardware } = input + if (!SUPPORTED_ARCH.has(hardware.arch)) { + return { + kind: "short", + hardware, + constraint: "arch", + requiredBytes: 0, + shortfallBytes: 0, + reason: `local models need an x64 or arm64 CPU; this machine reports ${hardware.arch}`, + } + } + + const requiredBytes = requirementFor(input.modelBytes) + if (hardware.availableBytes >= requiredBytes) return { kind: "fits", hardware } + + const shortfallBytes = requiredBytes - hardware.availableBytes + const fitsIfFreed = hardware.totalBytes >= requiredBytes + return { + kind: "short", + hardware, + constraint: "memory", + requiredBytes, + shortfallBytes, + reason: [ + `this model needs about ${gib(requiredBytes)} of memory`, + `(${gib(input.modelBytes)} of weights plus runtime headroom)`, + `and ${gib(hardware.availableBytes)} is free of ${gib(hardware.totalBytes)} total`, + fitsIfFreed + ? `-- short by ${gib(shortfallBytes)}, which this machine has if you close something` + : `-- short by ${gib(shortfallBytes)}, more than this machine has in total`, + ].join(" "), + } +} diff --git a/packages/core/src/config/plugin/local-model-pull.ts b/packages/core/src/config/plugin/local-model-pull.ts new file mode 100644 index 0000000000..333b66e226 --- /dev/null +++ b/packages/core/src/config/plugin/local-model-pull.ts @@ -0,0 +1,171 @@ +export * as LocalModelPull from "./local-model-pull" + +/** + * Listing and fetching models from a local runtime. + * + * PA-8's other half. The engine already runs a local model once one is present -- a local + * provider speaks the OpenAI wire protocol and `local-models.ts` lists what it serves. What + * was missing is everything before that: what is on disk, how big, and getting one. + * + * WHY THIS IS A SECOND SET OF ROUTES. The OpenAI-compatible `GET /models` the provider + * listing uses reports an id and nothing else -- no size. So it cannot answer the one + * question PA-8 turns on, which is whether this machine can run the thing. Sizes and the + * fetch itself live on the runtime's own API, so that is what this module speaks, and it is + * gated by the same local-endpoint rule. + * + * WE DO NOT FETCH WEIGHTS OURSELVES. No picking a mirror, no choosing a quantisation, no + * verifying a digest we chose to trust. The runtime already does all of it, resumes an + * interrupted download, and shares progress between concurrent callers -- its own docs say + * so. Re-implementing that would mean owning model provenance, which is a far larger + * promise than "run a model locally". + * + * Shapes here come from the runtime's published API reference, not from reading our own + * code back: `GET /api/tags` returns `models[].{name,size,digest,details}`, and + * `POST /api/pull` streams `{status}` objects that carry `{digest,total,completed}` while + * layers transfer. Two traps are documented there and both are handled below. + */ + +import { Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" + +import { isLocalEndpoint } from "./local-provider" + +/** Long, because a pull is a multi-gigabyte transfer and the caller is watching progress. */ +const PULL_TIMEOUT = "6 hours" + +/** Short, because listing what is already on disk is a local call that either answers or is down. */ +const LIST_TIMEOUT = "2 seconds" + +/** + * One installed model. `size` is the field the whole fit check depends on. + * + * Decoded ONE AT A TIME by the caller, for the reason `local-models.ts` states: one entry + * this build cannot describe should cost that entry, not the list. + */ +export const Installed = Schema.Struct({ + name: Schema.String, + size: Schema.Finite, + digest: Schema.optional(Schema.String), +}) +export type Installed = Schema.Schema.Type + +const TagList = Schema.Struct({ models: Schema.Array(Schema.Unknown) }) + +/** + * One progress frame from a pull. + * + * `total` and `completed` are BOTH optional, and that is not defensive typing -- the + * runtime's own docs say so. Status-only frames (`pulling manifest`, `verifying sha256 + * digest`, `writing manifest`, `success`) carry neither, and a downloading frame may carry + * `total` without `completed` until the first bytes land. + */ +export const PullFrame = Schema.Struct({ + status: Schema.String, + digest: Schema.optional(Schema.String), + total: Schema.optional(Schema.Finite), + completed: Schema.optional(Schema.Finite), + error: Schema.optional(Schema.String), +}) +export type PullFrame = Schema.Schema.Type + +/** Progress as a caller should show it. */ +export type Progress = { + /** The runtime's own status line, passed through rather than re-worded. */ + readonly status: string + /** Bytes transferred and expected FOR THE CURRENT LAYER, when the frame carries them. */ + readonly layerCompletedBytes?: number | undefined + readonly layerTotalBytes?: number | undefined + readonly done: boolean +} + +/** + * The runtime's own API lives at the ROOT, not under the OpenAI base path. + * + * A local runtime is configured with its OpenAI-compatible base, which is conventionally + * `http://host:port/v1` -- that is the URL the provider needs and the one a user copies out + * of the runtime's own banner. But `/api/tags` and `/api/pull` are siblings of `/v1`, not + * children: joining them onto the configured base produces `/v1/api/tags`, which answers 404. + * + * Measured, not reasoned about. The first version joined onto the base, and against a server + * serving the real `/api/tags` shape the command printed "answering, with nothing installed" + * -- a wrong statement that looked like a working feature, because a 404 and an empty list + * are both "no models" to a caller that does not separate them. + */ +const runtimeRoot = (base: string): string => base.replace(/\/+$/, "").replace(/\/v\d+$/, "") + +export const tagsUrl = (base: string): string => `${runtimeRoot(base)}/api/tags` +export const pullUrl = (base: string): string => `${runtimeRoot(base)}/api/pull` + +/** + * Read one progress frame. + * + * A frame with an `error` is a failure the runtime is reporting in-band, mid-stream, with a + * 200 already sent. Treated as terminal rather than as progress, because the alternative is + * a pull that looks like it is still going and never finishes. + */ +export const readFrame = (frame: PullFrame): Progress | { readonly error: string } => { + if (frame.error !== undefined && frame.error.length > 0) return { error: frame.error } + return { + status: frame.status, + layerCompletedBytes: frame.completed, + layerTotalBytes: frame.total, + done: frame.status === "success", + } +} + +/** + * Decode a newline-delimited stream body into frames, dropping only what fails. + * + * The runtime emits one JSON object per line and does not wrap them in an array, so a + * whole-body parse never succeeds. A line that does not decode is skipped: these frames are + * progress, and one unreadable frame must not abort a transfer that is working. + */ +export const readFrames = (body: string): ReadonlyArray => { + const frames: PullFrame[] = [] + for (const line of body.split("\n")) { + const trimmed = line.trim() + if (trimmed.length === 0) continue + try { + frames.push(Schema.decodeUnknownSync(PullFrame)(JSON.parse(trimmed))) + } catch { + continue + } + } + return frames +} + +/** + * What is installed on this runtime, with sizes. + * + * Best-effort for the same reason the provider listing is: a local runtime that is not + * running is the normal state of a laptop, not an error. A failed call returns an empty + * list, and the caller distinguishes "nothing installed" from "no runtime" by asking + * whether the runtime answered at all -- which is why `reachable` is returned separately + * rather than inferred from an empty array. + */ +export const installed = Effect.fn("LocalModelPull.installed")(function* (base: string) { + // Re-checked here rather than trusted from the caller, the same way `local-models.ts` + // re-checks it: two independent checks of one rule means a later change that loosens one + // cannot quietly turn this into a way to make the engine call an arbitrary host. + if (!isLocalEndpoint(base)) return { reachable: false, models: [] as ReadonlyArray } + + const client = yield* HttpClient.HttpClient + const result = yield* client + .execute(HttpClientRequest.get(tagsUrl(base))) + .pipe( + Effect.flatMap((response) => response.json), + Effect.timeout(LIST_TIMEOUT), + Effect.catchCause(() => Effect.succeed(undefined)), + ) + if (result === undefined) return { reachable: false, models: [] as ReadonlyArray } + + const list = Schema.decodeUnknownOption(TagList)(result) + if (list._tag === "None") return { reachable: true, models: [] as ReadonlyArray } + + const models: Installed[] = [] + for (const entry of list.value.models) { + const decoded = Schema.decodeUnknownOption(Installed)(entry) + if (decoded._tag === "Some") models.push(decoded.value) + } + return { reachable: true, models } +}) diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 765992a765..39b331aed0 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -8,6 +8,7 @@ import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" import { Global } from "../../global" import { Location } from "../../location" +import { CONSOLE_SKILLS_URL } from "../../plugin/provider/redrob-constants" export const Plugin = define({ id: "config-skill", @@ -19,7 +20,10 @@ export const Plugin = define({ Effect.fn(function* (draft) { const entries = yield* config.entries() const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + const items = [ + CONSOLE_SKILLS_URL, + ...entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])), + ] for (const directory of directories) { draft.source( SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), diff --git a/packages/core/src/config/privacy.ts b/packages/core/src/config/privacy.ts new file mode 100644 index 0000000000..df3cffcbb1 --- /dev/null +++ b/packages/core/src/config/privacy.ts @@ -0,0 +1,27 @@ +export * as ConfigPrivacy from "./privacy" + +import { Schema } from "effect" + +/** + * PII masking: what leaves this machine inside a model request. + * + * off nothing is touched (the default -- the user turns it on). + * regex emails, phone numbers, card numbers and Korean resident numbers are replaced by + * placeholders before any provider sees them, and put back in the answer. + * model regex, plus a LOCAL model (one the engine already serves, e.g. an Ollama model) names + * people, organisations and places in the new text so those are replaced too. + * + * The engine does this in one place for every provider and every app that runs on it, so an app + * only needs a switch, not its own masker. + */ +export const Mode = Schema.Literals(["off", "regex", "model"]) +export type Mode = Schema.Schema.Type + +export class Info extends Schema.Class("ConfigV2.Privacy")({ + mask_pii: Mode.pipe(Schema.optional).annotate({ + description: "Replace personal data with placeholders before a request leaves this machine", + }), + model: Schema.String.pipe(Schema.optional).annotate({ + description: "For mask_pii = model: the LOCAL model that names people, organisations and places, as provider/model", + }), +}) {} diff --git a/packages/core/src/console-key.ts b/packages/core/src/console-key.ts index 1b542b5dfe..02adc134fb 100644 --- a/packages/core/src/console-key.ts +++ b/packages/core/src/console-key.ts @@ -5,20 +5,24 @@ import { Effect } from "effect" import { Global } from "./global" import { Integration } from "./integration" +import { SharedAccount } from "./shared-account" /** * The console API key, from wherever the user actually put it. * - * THREE ORIGINS, and they are not interchangeable historically: + * FOUR ORIGINS, and they are not interchangeable historically: * * - `REDROB_API_KEY` is what a terminal user exports. * - The Credential store is what `redrob providers login` writes. It is SQL. * - `auth.json` is what `PUT /auth/redrob` writes, which is the route the DESKTOP APP uses for both of * its connect paths -- the pasted key and "Connect Redrob". That flow ends by handing the key to this * same route, so the app never populates the Credential store at all. + * - Redrob Launcher's shared `account.json` is what the launcher writes when it signs the whole computer + * in. It is read-only here and it is the LAST fallback: any key of our own wins over it, and deleting + * `auth.json` leaves it alone, so a disconnect falls back to it rather than deleting it. * - * Nothing bridges the last two: one is SQL, the other a file, and no migration copies either into the - * other. So every consumer has to read all three. + * Nothing bridges the middle two: one is SQL, the other a file, and no migration copies either into the + * other. So every consumer has to read all four. * * This lives in its own module because reading only some of them is a bug that has already shipped once: * the dynamic model catalogue checked the environment and the Credential store, so a user who connected @@ -47,15 +51,31 @@ function fromAuthStore(): Effect.Effect { } /** - * Resolve the key, in the order a user would expect to win. + * Where a resolved key came from. These are the `source` values the provider report exposes for the + * `redrob` provider, so a host app can say "Signed in with Redrob Launcher" without ever seeing the key: + * `env` is `REDROB_API_KEY`, `credential` the SQL store, `api` the engine's own `auth.json`, and + * `launcher` the shared file. + */ +export type Source = "env" | "credential" | "api" | "launcher" + +export interface Resolved { + readonly key: string + readonly source: Source +} + +const found = (key: string, source: Source): Resolved => ({ key, source }) + +/** + * Resolve the key and say where it came from, in the order a user would expect to win. * * The environment first, because an explicitly exported value is the one a person set most recently and * most deliberately -- overriding a stored credential for one run is a thing people do, and the reverse - * would make that impossible. + * would make that impossible. The launcher's shared file last, because it is not ours: it is the sign-in + * for the whole computer, and anything this app holds itself is the more specific choice. */ -export const resolve = Effect.fn("ConsoleKey.resolve")(function* () { +export const resolveWithSource = Effect.fn("ConsoleKey.resolveWithSource")(function* () { const fromEnv = process.env["REDROB_API_KEY"] - if (fromEnv) return fromEnv + if (fromEnv) return found(fromEnv, "env") /* Read through `Integration`, not `Credential` directly. Both reach the same store, but `Credential` is not @@ -69,8 +89,21 @@ export const resolve = Effect.fn("ConsoleKey.resolve")(function* () { const value = yield* integration.connection .resolve(connection) .pipe(Effect.orElseSucceed(() => undefined)) - if (value?.type === "key" && value.key.trim()) return value.key + if (value?.type === "key" && value.key.trim()) return found(value.key, "credential") } - return yield* fromAuthStore() + const fromFile = yield* fromAuthStore() + if (fromFile) return found(fromFile, "api") + + // Read fresh every time, per the contract: the person may sign in or out of the launcher while we run. + const shared = yield* Effect.promise(() => SharedAccount.apiKey()) + if (shared) return found(shared, "launcher") + + return undefined +}) + +/** The key alone, for callers that only need to spend it. */ +export const resolve = Effect.fn("ConsoleKey.resolve")(function* () { + const resolved = yield* resolveWithSource() + return resolved?.key }) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7da67673c3..22b1a14283 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -22,6 +22,7 @@ import { Policy } from "./policy" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" import { QuestionV2 } from "./question" +import { BrowserRequestV1 } from "./browser-request" import { Reference } from "./reference" import { ReferenceGuidance } from "./reference/guidance" import * as SessionRunnerLLM from "./session/runner/llm" @@ -29,6 +30,7 @@ import { SessionRunnerModel } from "./session/runner/model" import { SessionTodo } from "./session/todo" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" +import { Privacy } from "./privacy/service" import { Snapshot } from "./snapshot" import { SystemContextBuiltIns } from "./system-context/builtins" import { SystemContextRegistry } from "./system-context/registry" @@ -71,9 +73,11 @@ export const locationServices = LayerNode.group([ ReferenceGuidance.node, SessionTodo.node, QuestionV2.node, + BrowserRequestV1.node, ReadToolFileSystem.node, BuiltInTools.node, SessionRunnerModel.node, + Privacy.node, Snapshot.node, SessionRunnerLLM.node, ]) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 4a7b17d587..5068ca2d0b 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -8,6 +8,7 @@ import { InstallationChannel, InstallationVersion } from "./installation/version import { Credential } from "./credential" import { EventV2 } from "./event" import { Integration } from "./integration" +import { SharedAccount } from "./shared-account" import { makeGlobalNode } from "./effect/app-node" import { httpClient } from "./effect/app-node-platform" import { @@ -505,7 +506,7 @@ const layer = Layer.effect( /** * The console key, from wherever the user actually put it. * - * Three origins, and they are not interchangeable historically. `REDROB_API_KEY` is the one a + * Four origins, and they are not interchangeable historically. `REDROB_API_KEY` is the one a * terminal user exports. The Credential store is what `redrob providers login` writes. And * `auth.json` is what `PUT /auth/redrob` writes, which is the route the DESKTOP APP uses for both * of its connect paths, the pasted key and "Connect Redrob" - that flow ends by handing the key to @@ -520,6 +521,9 @@ const layer = Layer.effect( * Note the shapes differ as well as the locations: the Credential store discriminates on * `type: "key"`, auth.json on `type: "api"`. Matching only one of those spellings was the second * half of the same bug. + * + * The fourth is Redrob Launcher's shared `account.json`, the sign-in for the whole computer. It is + * read-only and it is last: anything this app holds itself wins over it. */ const resolveApiKey = Effect.fn("ModelsDev.resolveApiKey")(function* () { const fromEnv = process.env["REDROB_API_KEY"] @@ -527,7 +531,11 @@ const layer = Layer.effect( const stored = yield* credential.list(Integration.ID.make("redrob")) const fromCredential = stored.flatMap((item) => (item.value.type === "key" ? [item.value.key] : []))[0] if (fromCredential) return fromCredential - return yield* authStoreApiKey() + const fromFile = yield* authStoreApiKey() + if (fromFile) return fromFile + // Fourth and last: Redrob Launcher's shared sign-in for the whole computer. Read fresh on every + // call, never cached, and only when nothing of our own is present (see shared-account.ts). + return yield* Effect.promise(() => SharedAccount.apiKey()) }) const populate = Effect.gen(function* () { diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 8a1f3437d3..b73d189695 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -211,7 +211,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int skill.transform((draft) => callback({ source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)), - list: draft.list, + // The plugin API is typed from the generated SDK, which has no readonly arrays. + list: () => mutable(draft.list()), }), ), }, diff --git a/packages/core/src/plugin/provider/redrob-constants.ts b/packages/core/src/plugin/provider/redrob-constants.ts index e4fa82e0ec..52a7118972 100644 --- a/packages/core/src/plugin/provider/redrob-constants.ts +++ b/packages/core/src/plugin/provider/redrob-constants.ts @@ -21,11 +21,14 @@ import { Flag } from "../../flag/flag" // credentials come from: a local console still wants a key it recognises. Importing Flag is safe from // this leaf module because `flag/flag.ts` imports only `effect` -- the cycle this file avoids is // through the plugin and event modules, which Flag does not touch. -export const CONSOLE_URL = (Flag.REDROB_CONSOLE_URL ?? "https://console.redrob.ai/api/backend/v1").replace( - /\/+$/, - "", -) +export const CONSOLE_URL = (Flag.REDROB_CONSOLE_URL ?? "https://console.redrob.ai/api/backend/v1").replace(/\/+$/, "") export const CONSOLE_PACKAGE = "@ai-sdk/openai-compatible" + +/** + * The Console's skill library (`/skills/index.json` and one directory per skill). Every engine pulls + * it, so every Redrob app that runs on the engine has the same skills; `config.skills` adds more. + */ +export const CONSOLE_SKILLS_URL = new URL("/skills/", CONSOLE_URL).href // Every served console model publishes `capabilities.maxContextTokens: 1_000_000`. Held as one // constant because the value is uniform across the six ids, and used as the no-key fallback // context window. A zero here is not cosmetic: `usable()` in packages/redrob/src/session/overflow.ts diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 5295d349d0..6bd98a479b 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -4,28 +4,89 @@ export * as SkillPlugin from "./skill" import { define } from "./internal" import { Effect } from "effect" -import { AbsolutePath } from "../schema" +import { AbsolutePath, type DeepMutable } from "../schema" import { SkillV2 } from "../skill" import customizeRedrobContent from "./skill/customize-redrob.md" with { type: "text" } +import documentToolchainContent from "./skill/document-toolchain.md" with { type: "text" } +import pageControlContent from "./skill/page-control.md" with { type: "text" } +import skillWriterContent from "./skill/skill-writer.md" with { type: "text" } + +const mutable = (value: T) => value as DeepMutable export const CustomizeRedrobContent = customizeRedrobContent +/** K-3's skill document. Exported so a test can assert its claims against the real tool tree. */ +export const DocumentToolchainContent = documentToolchainContent + +/** K-5's skill document. Exported so a test can round-trip the template it prescribes. */ +export const SkillWriterContent = skillWriterContent + +/** PA-9's skill document. Exported so a test can assert it against the real `page` surface. */ +export const PageControlContent = pageControlContent + +/** + * The skills the engine ships with. Exported as data rather than built inline in the plugin + * effect so a test can decode every entry through `SkillV2.Info` — `Info.make` does not run + * the decoder, so a field in a shape the schema rejects would otherwise reach a session. + */ +export const BuiltinSkills: ReadonlyArray = [ + SkillV2.Info.make({ + name: "customize-redrob", + description: + "Use ONLY when the user is editing or creating redrob's own configuration: redrob.json, redrob.jsonc, files under .redrob/, or files under ~/.config/redrob/. Also use when creating or fixing redrob agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring redrob itself.", + location: AbsolutePath.make("/builtin/customize-redrob.md"), + content: CustomizeRedrobContent, + }), + // K-3. The keywords are the words a request for this work actually contains; the URL globs + // are the web editors where a document is the thing on screen. + SkillV2.Info.make({ + name: "documents", + description: + "Use when reading or writing a Word, Excel, PowerPoint or PDF file in code mode: the `docx`, `xlsx`, `pptx` and `pdf` globals, their exact read and write shapes, and which parts of a PDF genuinely cannot be read. Use it before writing any program that touches a document file. Do not use for plain text, markdown, CSV or JSON, which need no document toolchain.", + location: AbsolutePath.make("/builtin/document-toolchain.md"), + autoInject: SkillV2.AutoInject.make({ + keywords: ["docx", "xlsx", "pptx", "pdf", "spreadsheet", "powerpoint", "word document", "slide deck"], + url: [ + "docs.google.com/document/**", + "docs.google.com/spreadsheets/**", + "docs.google.com/presentation/**", + "*.officeapps.live.com/**", + ], + }), + content: DocumentToolchainContent, + }), + // PA-9. `page` resolves through the browser channel now, so this document describes calls + // that actually run. The keywords are what a request to read or drive the page contains; + // no url globs, because arming on a URL would put it in every session on any page. + SkillV2.Info.make({ + name: "page-control", + description: + "Use when the task is about the page the user is looking at: reading its text, finding nodes by selector, filling a field, clicking, or navigating. Covers the six `page` calls in code mode, what a refusal means, and the round-trip cost. Do not use for fetching a URL the user is not on, which is a plain HTTP request.", + location: AbsolutePath.make("/builtin/page-control.md"), + autoInject: SkillV2.AutoInject.make({ + keywords: ["this page", "current page", "on screen", "the tab", "click the", "fill in", "selector", "scrape"], + }), + content: PageControlContent, + }), + // K-5. No `autoInject`: writing a skill is something a person asks for by name, and a skill + // that armed on the word "skill" would be in the prompt of every session that mentions one. + SkillV2.Info.make({ + name: "skill-writer", + description: + "Use when creating a new redrob skill, or fixing one that does not load: where SKILL.md must sit for the loader to find it, every frontmatter key the loader actually reads, the auto-arming block, and the template to copy. Do not use for agents, commands or plugins, which are different files with different shapes.", + location: AbsolutePath.make("/builtin/skill-writer.md"), + content: SkillWriterContent, + }), +] + export const Plugin = define({ id: "skill", effect: Effect.fn(function* (ctx) { yield* ctx.skill.transform((draft) => { - draft.source( - SkillV2.EmbeddedSource.make({ - type: "embedded", - skill: SkillV2.Info.make({ - name: "customize-redrob", - description: - "Use ONLY when the user is editing or creating redrob's own configuration: redrob.json, redrob.jsonc, files under .redrob/, or files under ~/.config/redrob/. Also use when creating or fixing redrob agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring redrob itself.", - location: AbsolutePath.make("/builtin/customize-redrob.md"), - content: CustomizeRedrobContent, - }), - }), - ) + for (const skill of BuiltinSkills) { + // The plugin API is typed from the generated SDK, which has no readonly arrays. + draft.source(mutable(SkillV2.EmbeddedSource.make({ type: "embedded", skill }))) + } }) }), }) diff --git a/packages/core/src/plugin/skill/document-toolchain.md b/packages/core/src/plugin/skill/document-toolchain.md new file mode 100644 index 0000000000..30f2cdf539 --- /dev/null +++ b/packages/core/src/plugin/skill/document-toolchain.md @@ -0,0 +1,125 @@ + + +# Reading and writing documents in code mode + +Code mode binds four document objects as bare globals: `docx`, `xlsx`, `pptx` and `pdf`. +Each has exactly two methods, `read` and `write`. They are part of the language, not an MCP +server, so they are there whether or not anything is connected. + +**A `write` produces a real file.** It serialises through a format library, lands bytes on +disk, and returns `{ path, bytes }` where `bytes` is the size the filesystem reports for the +file that is now there. There is no mode in which a write returns a summary of a document it +did not produce. If a write cannot be done it throws and says why. + +Paths may be relative — they resolve against the instance directory — or absolute. Writing +outside the instance directory asks the user for permission first, and a refusal throws +rather than writing somewhere else. + +## docx — Word + +```js +const back = await docx.read({ path: "report.docx" }) +// => { paragraphs: string[], text: string } + +await docx.write({ + path: "report.docx", + title: "Quarterly report", + paragraphs: [ + { text: "Revenue grew.", heading: 1 }, + { text: "Costs held flat.", bold: true }, + { text: "Headcount unchanged." }, + ], +}) +// => { path: "/abs/report.docx", bytes: 9445 } +``` + +`read` returns the body's paragraph text in document order, and `text` is those paragraphs +joined with newlines. It does not reconstruct styling, and it does not descend into headers, +footers or footnotes — so a paragraph that only exists in a header will not appear. + +`heading` is a level from 1 to 6. `title` is optional and renders as the document title. + +## xlsx — Excel + +```js +const book = await xlsx.read({ path: "numbers.xlsx" }) +// => { sheets: [{ name: "Totals", rows: [["region", "units"], ["EMEA", 1200]] }] } + +const one = await xlsx.read({ path: "numbers.xlsx", sheet: "Totals" }) + +await xlsx.write({ + path: "numbers.xlsx", + sheets: [{ name: "Totals", rows: [["region", "units"], ["EMEA", 1200]] }], +}) +``` + +Cells come back as strings, numbers, booleans or `null`. A date becomes an ISO 8601 string. +A formula cell gives its cached result, and `null` when the file carries no cached result — +never the formula text, which is not the cell's value. Naming a `sheet` that does not exist +throws instead of returning an empty list. + +## pptx — PowerPoint + +```js +const deck = await pptx.read({ path: "deck.pptx" }) +// => { slides: [{ index: 0, text: ["Slide 1", "point 1a"] }] } + +await pptx.write({ + path: "deck.pptx", + slides: [{ title: "Findings", bullets: ["One", "Two"], notes: "say it slowly" }], +}) +``` + +`read` gives the text of each slide in deck order, `index` counting from zero. It reads the +slides themselves, not layouts or masters, so boilerplate that lives on a master is absent. + +## pdf — PDF + +```js +const doc = await pdf.read({ path: "brief.pdf" }) +// => { pageCount, title?, author?, pages: [{ index, text, textComplete, reason? }] } + +await pdf.write({ + path: "brief.pdf", + title: "Engine brief", + fontSize: 12, + pages: [{ lines: ["First line", "Second line"] }], +}) +``` + +`pdf.write` works: it produces a real PDF, one page per entry, lines laid out in Helvetica, +and a page that runs out of room continues onto a new one rather than dropping text. A +character outside WinAnsi is written as `?`, because the standard fonts cannot encode it. + +**`pdf.read` extracts text from content streams; it does not render the page.** Read +`textComplete` before you trust `text`: + +- `textComplete: true` — the page's text was recovered as characters. +- `textComplete: false` with a `reason` — some or all of it could not be. The three reasons + are a page whose text is drawn with a composite (`Type0`) font, where the string bytes are + glyph identifiers rather than characters and are therefore skipped; a page with no readable + content stream, which is a scan or an image and there is no OCR in the engine; and a page + that draws only graphics. + +A page reported `textComplete: false` has not been read. Say so to the user instead of +treating its `text` as the page's content. + +## When a document call fails + +Both failure kinds throw with a sentence naming the object and the operation: + +- `` `docx.read` failed: no file at /abs/absent.docx `` — the call reached the format layer + and that layer refused. Wrong path, wrong container, a library rejection. +- `` The `pdf` domain object is not available in this session: … `` — the capability itself is + absent. This is what a catalog preview returns, because a preview has no directory to + resolve a path against and is not a live execution. + +Neither is recoverable by retrying the same call. Report the sentence; do not substitute a +plausible value for a document you could not read. diff --git a/packages/core/src/plugin/skill/page-control.md b/packages/core/src/plugin/skill/page-control.md new file mode 100644 index 0000000000..4db636c702 --- /dev/null +++ b/packages/core/src/plugin/skill/page-control.md @@ -0,0 +1,91 @@ +# Reading and driving the page in code mode + +Use this when the task is about the page the user is looking at: read it, find things in +it, fill a field, click, or follow a link. In code mode you write a program and the `page` +global is in scope. + +## What exists + +Six calls. There is no seventh — if you need something else, say so rather than inventing +a call that will not resolve. + +```ts +await page.url() // string — the current URL +await page.text() // string — the visible text +await page.query({ selector, limit }) // PageNode[] — in document order +await page.click({ selector }) // void +await page.type({ selector, text, submit }) // void +await page.navigate({ url }) // string — the URL actually landed on +``` + +A `PageNode` is `{ selector, text, attributes }`. It is a snapshot, not a handle: you +cannot hold one and act on it later. Act by selector. + +## Where the page is + +Not in this process. The engine runs beside the browser and cannot reach into it, so each +call is a request the browser answers. Three consequences, all of which you will meet: + +1. **Every call can refuse.** When no browser is attached, the call fails with + `DomainUnavailableError` and the message names the action. That is the honest answer, not + a bug to work around. Tell the user the browser is not attached. +2. **A refusal is also the answer for a tab the user has not exposed**, and for the + browser's own pages (`chrome://`, the extension's pages). You cannot read those, and + nothing you write will change that. +3. **Each call is a round trip.** Three queries cost three round trips. Prefer one + `page.text()` over ten `page.query()` calls when you only need to read, and one + `page.query()` with a `limit` over a loop. + +## Reading before acting + +`page.text()` is the cheapest way to see what is on screen, and it is usually enough to +answer a question about the page. Reach for `page.query()` when you need structure — the +href of each link, the value of an attribute, which of several forms is which. + +```ts +const links = await page.query({ selector: "a[href]", limit: 50 }) +const external = links.filter((node) => !node.attributes.href?.startsWith("/")) +``` + +`query` returns `[]` when the selector matched nothing. That is a fact about the page, and +it is different from a refusal, which is a fact about the session. Do not treat an empty +array as "no browser" — check the error path for that. + +## Filling a field + +`page.type` targets the first node matching the selector and types into it. `submit: true` +submits the form afterwards, which is one round trip instead of a type and a click. + +```ts +await page.type({ selector: "input[name=q]", text: "redrob", submit: true }) +``` + +Type into the field, not into its wrapper: a selector that matches a `div` around the input +has nothing to receive text. Pick the `input`, `textarea` or `[contenteditable]` itself, +and confirm with `page.query` first when the markup is unfamiliar. + +## Navigating + +`page.navigate` returns the URL actually landed on, which is frequently not the one you +asked for — a redirect, a login wall, a trailing-slash normalisation. Read the return value +before assuming you are where you aimed. + +```ts +const landed = await page.navigate({ url: "https://example.com/report" }) +if (!landed.includes("/report")) { + // A login wall or a redirect. Say so; do not keep clicking. +} +``` + +## What not to do + +Do not poll. There is no wait call, and a loop of `page.text()` until something changes +is a round trip per iteration against a page that may never change. Read once, act, read +once more. + +Do not scrape a credential. The page the user is on may be signed in; reading a token out +of it and putting it in your answer or a file is an exfiltration, not a task step. + +Do not guess at a selector you have not seen. One `page.query({ selector: "form", limit: 5 +})` costs one round trip and tells you the shape; a wrong selector costs a refusal and a +confused user. diff --git a/packages/core/src/plugin/skill/skill-writer.md b/packages/core/src/plugin/skill/skill-writer.md new file mode 100644 index 0000000000..40ee892f4b --- /dev/null +++ b/packages/core/src/plugin/skill/skill-writer.md @@ -0,0 +1,104 @@ + + +# Writing a skill + +A skill is one markdown file: a YAML frontmatter block that says what the skill is, and a +body that says what to do. Nothing registers it — the loader finds it by where it sits. + +## Where the file goes + +The loader globs `{*.md,**/SKILL.md}` under each skill directory, so exactly two layouts are +found and no others: + +| layout | path | name comes from | +| --- | --- | --- | +| directory (prefer this) | `.redrob/skill//SKILL.md` | frontmatter `name`, else nothing — a `SKILL.md` with no `name` is dropped | +| single file | `.redrob/skill/.md` | frontmatter `name`, else the filename without `.md` | + +- Project scope: `.redrob/skill/` or `.redrob/skills/`. +- Global scope: `~/.config/redrob/skill/` or `~/.config/redrob/skills/`. + +Use the directory layout and always write a `name`. A skill in a subdirectory whose +frontmatter has no `name` is dropped by the loader with an `unnamed:` reason, because the +path gives it no fallback — that is the single most common way a new skill silently fails to +appear. + +Two skills with the same `name` collide and the later source wins. Check the existing names +before choosing one. + +## The frontmatter + +Only these keys are read. Anything else is ignored, so a misspelled key is not an error — +it is a skill that quietly does not do the thing you meant. + +| key | type | effect | +| --- | --- | --- | +| `name` | string | the skill's identity. Write it. | +| `description` | string | when to use the skill. This is what the model reads to decide. | +| `slash` | boolean | expose it as a slash command. | +| `icon` | string | icon URL, for surfaces that list skills. | +| `autoInject.keywords` | string array | case-insensitive words matched against the prompt on word boundaries. | +| `autoInject.url` | string array | globs matched against the current tab URL. | + +`autoInject` is what arms the skill without the user naming it. A skill with **no** +`autoInject` block never auto-arms and stays explicitly loadable — which is the right choice +for most skills. `autoInject: {}` with both lists absent also never arms: an empty list +matches nothing, deliberately, because a skill that arms on everything is a skill that is +always in the prompt. + +A malformed `autoInject` costs only the arming. The skill still loads, and a warning names +the file and the reason. A malformed frontmatter block drops the whole document. + +## The template + + + +```markdown +--- +name: release-notes +description: Use when drafting release notes from a tag range, or when the user asks what changed between two versions. +slash: true +icon: https://code.redrob.ai/icon/release-notes.svg +autoInject: + keywords: + - release notes + - changelog + url: + - github.com/*/releases/** +--- + +# Drafting release notes + +State what the skill should do, in the order it should be done, and what to refuse. +Write for an agent that has the repository in front of it and no other context. +``` + +Copy that block, replace every value, delete the keys you do not need — `name` and +`description` are the two worth always keeping — and write it to the path chosen above. + +## Writing the body + +The body is the instruction, and it is read by an agent that has nothing else. So: + +- Name the concrete surface: the file, the command, the global, the endpoint. Not "the + relevant config". +- Say what to refuse and what to ask about, not only what to do. +- Every claim about a tool, a global or a flag must be true. A skill that describes a method + that does not exist produces an agent that calls it and fails. +- Keep it short enough to be read whole. A skill is a page, not a manual. + +## After writing + +Skills are loaded when redrob starts and are not hot-reloaded. Tell the user to restart +redrob, then confirm the skill appears. If it does not, the frontmatter was rejected or the +file is outside a skill directory — the startup log names the file and the reason in both +cases. diff --git a/packages/core/src/privacy/mask.ts b/packages/core/src/privacy/mask.ts new file mode 100644 index 0000000000..c384ac2df0 --- /dev/null +++ b/packages/core/src/privacy/mask.ts @@ -0,0 +1,244 @@ +export * as Mask from "./mask" + +import type { ContentPart, LLMEvent, LLMRequest, Message } from "@redrob-code/llm" +import type { ConfigPrivacy } from "../config/privacy" + +/** + * Replaces personal data in a request with placeholders, and puts it back in the answer. + * + * One Vault per session. A value gets one placeholder for the life of the process, so the model sees + * the same `[PERSON_1]` in turn one and turn five and can refer back to it; the real value never goes + * out and comes back in every text delta and every tool-call input the model produces, so tools run + * on the real thing and the person reads the real thing. + * + * ponytail: the vault lives in memory. After a restart an old value gets a fresh index, which costs + * the model one cross-reference and nothing else. Persist it if that ever matters. + */ + +export type Kind = "EMAIL" | "PHONE" | "CARD" | "RRN" | "PERSON" | "ORG" | "LOC" + +/** A found span: what text, and what kind of thing it is. */ +export type Entity = { readonly value: string; readonly kind: Kind } + +/* ------------------------------------------------------------------ detectors (regex tier) */ + +const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g +/* +82 10-1234-5678, 010-1234-5678, (02) 123-4567, +1 415 555 0100 -- 9 to 15 digits with separators. */ +const PHONE = /(? { + let sum = 0 + let double = false + for (let index = digits.length - 1; index >= 0; index--) { + let digit = Number(digits[index]) + if (double) { + digit *= 2 + if (digit > 9) digit -= 9 + } + sum += digit + double = !double + } + return sum % 10 === 0 +} + +/** The regex tier: what can be found without a model. Order matters -- RRN before PHONE, so a resident number is not read as a phone number. */ +export const detect = (text: string): Entity[] => { + const found: Entity[] = [] + for (const match of text.matchAll(EMAIL)) found.push({ value: match[0], kind: "EMAIL" }) + for (const match of text.matchAll(RRN)) found.push({ value: match[0], kind: "RRN" }) + for (const match of text.matchAll(CARD)) { + if (luhn(match[0].replace(/\D/g, ""))) found.push({ value: match[0], kind: "CARD" }) + } + for (const match of text.matchAll(PHONE)) found.push({ value: match[0], kind: "PHONE" }) + return found +} + +/* ------------------------------------------------------------------ vault */ + +const PLACEHOLDER = /\[(EMAIL|PHONE|CARD|RRN|PERSON|ORG|LOC)_(\d+)\]/g +/* A placeholder cut off by a chunk boundary: `[`, `[PER`, `[PERSON_`, `[PERSON_1`. */ +const PARTIAL = /\[(?:[A-Z]{0,6}(?:_\d*)?)?$/ + +const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +export class Vault { + private readonly forward = new Map() + private readonly backward = new Map() + private readonly counters = new Map() + + /** Teach the vault a value; returns its placeholder (the same one every time). */ + add(entity: Entity): string { + const known = this.forward.get(entity.value) + if (known) return known + const next = (this.counters.get(entity.kind) ?? 0) + 1 + this.counters.set(entity.kind, next) + const placeholder = `[${entity.kind}_${next}]` + this.forward.set(entity.value, placeholder) + this.backward.set(placeholder, entity.value) + return placeholder + } + + get size() { + return this.forward.size + } + + private readonly scanned = new Set() + + /** The texts the model tier has not read yet; marks them read. Keeps the local call to the new turn, not the whole history. */ + fresh(texts: readonly string[]): string[] { + const result = texts.filter((text) => !this.scanned.has(text)) + for (const text of result) this.scanned.add(text) + return result + } + + /** Replace every known value in `text` by its placeholder. Longest value first, so a name inside an email is not half-replaced. */ + mask(text: string): string { + if (this.forward.size === 0) return text + const values = [...this.forward.keys()].sort((a, b) => b.length - a.length) + return text.replace(new RegExp(values.map(escape).join("|"), "g"), (value) => this.forward.get(value) ?? value) + } + + /** Detect with the regex tier, teach, then mask. */ + maskText(text: string): string { + for (const entity of detect(text)) this.add(entity) + return this.mask(text) + } + + unmask(text: string): string { + if (this.backward.size === 0) return text + return text.replace(PLACEHOLDER, (placeholder) => this.backward.get(placeholder) ?? placeholder) + } + + /** + * Unmask a stream of text chunks. A placeholder can be split across two deltas (`[PERSON_` + + * `1]`), so a trailing partial is held back and prepended to the next chunk; `flush` returns it + * when the block ends. + */ + unmasker() { + let carry = "" + return { + push: (chunk: string): string => { + const text = carry + chunk + const partial = PARTIAL.exec(text) + carry = partial ? partial[0] : "" + return this.unmask(partial ? text.slice(0, partial.index) : text) + }, + flush: (): string => { + const rest = carry + carry = "" + return rest + }, + } + } + + /** Walk any JSON-ish value and unmask every string in it (tool-call inputs). */ + unmaskDeep(value: T): T { + if (typeof value === "string") return this.unmask(value) as T + if (Array.isArray(value)) return value.map((item) => this.unmaskDeep(item)) as T + if (value && typeof value === "object") + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.unmaskDeep(item)])) as T + return value + } +} + +/* ------------------------------------------------------------------ request / event mapping */ + +const maskPart = (vault: Vault, part: ContentPart): ContentPart => { + if (part.type === "text") return { ...part, text: vault.maskText(part.text) } + if (part.type === "tool-result" && part.result.type === "text" && typeof part.result.value === "string") + return { ...part, result: { ...part.result, value: vault.maskText(part.result.value) } } + /* ponytail: json tool results and media are passed through. Mask them when a tool is found to leak. */ + return part +} + +/** The request as the provider will see it. The system prompt is ours and is left alone. */ +export const maskRequest = (vault: Vault, request: LLMRequest): LLMRequest => { + const messages: Message[] = request.messages.map((message) => ({ + ...message, + content: message.content.map((part) => maskPart(vault, part)), + })) + return { ...request, messages } +} + +/** The texts of a request a model-tier detector should read: user and tool text, never the system prompt. */ +export const readableTexts = (request: LLMRequest): string[] => + request.messages + .flatMap((message) => message.content) + .flatMap((part) => { + if (part.type === "text") return [part.text] + if (part.type === "tool-result" && part.result.type === "text" && typeof part.result.value === "string") + return [part.result.value] + return [] + }) + +/** + * Put real values back into what the model produced. Text deltas go through a per-block unmasker; + * tool-call inputs are unmasked whole, because the tool is about to run on them. + */ +export const unmaskEvents = (vault: Vault) => { + const blocks = new Map>() + return (event: LLMEvent): LLMEvent[] => { + if (event.type === "text-delta") { + const block = blocks.get(event.id) ?? vault.unmasker() + blocks.set(event.id, block) + return [{ ...event, text: block.push(event.text) }] + } + if (event.type === "text-end") { + const rest = blocks.get(event.id)?.flush() ?? "" + blocks.delete(event.id) + return rest ? [{ type: "text-delta", id: event.id, text: vault.unmask(rest) }, event] : [event] + } + if (event.type === "tool-call") return [{ ...event, input: vault.unmaskDeep(event.input) }] + return [event] + } +} + +/* ------------------------------------------------------------------ settings */ + +export type Settings = { readonly mode: ConfigPrivacy.Mode; readonly model?: string } + +export const settings = (entries: readonly { readonly info?: { readonly privacy?: ConfigPrivacy.Info } }[]): Settings => + entries.reduce( + (result, entry) => ({ + mode: entry.info?.privacy?.mask_pii ?? result.mode, + model: entry.info?.privacy?.model ?? result.model, + }), + { mode: "off" }, + ) + +/* ------------------------------------------------------------------ model tier */ + +/** What the local model is asked. JSON only, so the answer parses or is ignored. */ +export const ENTITY_PROMPT = `List every personal name, organisation name and street address or place name that identifies a person in the text below. Answer with JSON only, in this exact shape and nothing else: +{"PERSON":["..."],"ORG":["..."],"LOC":["..."]} +Copy each value exactly as written in the text. Use empty arrays when there is none. + +TEXT: +` + +/** Parse the local model's answer. Anything that is not the asked-for shape is ignored, never trusted. */ +export const parseEntities = (answer: string): Entity[] => { + const start = answer.indexOf("{") + const end = answer.lastIndexOf("}") + if (start < 0 || end <= start) return [] + let parsed: unknown + try { + parsed = JSON.parse(answer.slice(start, end + 1)) + } catch { + return [] + } + if (!parsed || typeof parsed !== "object") return [] + const kinds: Kind[] = ["PERSON", "ORG", "LOC"] + return kinds.flatMap((kind) => { + const values = (parsed as Record)[kind] + return Array.isArray(values) + ? values + .filter((value): value is string => typeof value === "string" && value.trim().length > 1) + .map((value) => ({ value: value.trim(), kind })) + : [] + }) +} diff --git a/packages/core/src/privacy/service.ts b/packages/core/src/privacy/service.ts new file mode 100644 index 0000000000..3f28669fbd --- /dev/null +++ b/packages/core/src/privacy/service.ts @@ -0,0 +1,70 @@ +export * as Privacy from "./service" + +import fs from "fs/promises" +import path from "path" +import { Context, Effect, Layer, Ref } from "effect" +import { applyEdits, modify } from "jsonc-parser" +import { Config } from "../config" +import { ConfigPrivacy } from "../config/privacy" +import { makeLocationNode } from "../effect/app-node" +import { Global } from "../global" +import { Mask } from "./mask" + +/** + * The privacy switch an app flips: what `mask_pii` is right now, and how to change it. + * + * Config documents are read once when a location opens, so a value written to the file alone would + * take effect on the next restart. This holds the live value too: `set` changes it for the next + * message AND writes it to the user's global `redrob.json`, so it survives a restart and shows up in + * every location on this machine. One engine, one switch, every app that runs on it. + */ +export interface Interface { + readonly get: () => Effect.Effect + readonly set: (settings: Mask.Settings) => Effect.Effect +} + +export class Error extends globalThis.Error { + readonly _tag = "PrivacyWriteError" +} + +export class Service extends Context.Service()("@redrob/v2/Privacy") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const global = yield* Global.Service + const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") + const current = yield* Ref.make(Mask.settings(documents)) + const file = path.join(global.config, "redrob.json") + + const write = (settings: Mask.Settings) => + Effect.tryPromise({ + try: async () => { + const text = await fs.readFile(file, "utf8").catch(() => "{}") + const value: ConfigPrivacy.Info = { mask_pii: settings.mode, model: settings.model } + const edits = modify(text, ["privacy"], value, { formattingOptions: { tabSize: 2, insertSpaces: true } }) + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, applyEdits(text, edits)) + }, + catch: (cause) => + new Error(`Could not write ${file}: ${cause instanceof globalThis.Error ? cause.message : String(cause)}`), + }) + + return Service.of({ + get: () => Ref.get(current), + set: Effect.fn("Privacy.set")(function* (settings) { + const next: Mask.Settings = { mode: settings.mode, model: settings.model?.trim() || undefined } + yield* write(next) + yield* Ref.set(current, next) + return next + }), + }) + }), +) + +/** Test seam: a fixed setting, no file. */ +export const layerWith = (settings: Mask.Settings) => + Layer.succeed(Service, Service.of({ get: () => Effect.succeed(settings), set: (next) => Effect.succeed(next) })) + +export const node = makeLocationNode({ service: Service, layer, deps: () => [Config.node, Global.node] }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 02cfca1759..5cf5be7b34 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -38,6 +38,8 @@ import { toLLMMessages } from "./to-llm-message" import { MAX_STEPS_PROMPT } from "./max-steps" import { Snapshot } from "../../snapshot" import { makeLocationNode } from "../../effect/app-node" +import { Mask } from "../../privacy/mask" +import { Privacy } from "../../privacy/service" import { llmClient } from "../../effect/app-node-platform" /** @@ -90,6 +92,13 @@ import { llmClient } from "../../effect/app-node-platform" * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. */ +/** + * Which Redrob app is driving this engine (cowork, office, design, code), set by the app on the + * engine process it starts. Sent as `x-redrob-app` so the Console can group AI work by app. It is a + * label and nothing else: an unset value sends no header. + */ +const APP_NAME = process.env.REDROB_APP?.trim() || undefined + const layer = Layer.effect( Service, Effect.gen(function* () { @@ -107,6 +116,55 @@ const layer = Layer.effect( const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() }) + const privacyService = yield* Privacy.Service + /** + * PII masking, one vault per session, read from the privacy service on every turn so a switch + * flipped in the app takes effect on the next message. Off by default. + */ + const vaults = new Map() + const vaultFor = (sessionID: SessionSchema.ID) => { + const vault = vaults.get(sessionID) ?? new Mask.Vault() + vaults.set(sessionID, vault) + return vault + } + /** Model tier: ask the configured LOCAL model for names, organisations and places in the text, and teach the vault. */ + const learnEntities = Effect.fn("SessionRunner.learnEntities")(function* ( + session: SessionSchema.Info, + vault: Mask.Vault, + modelRef: string, + text: string, + ) { + const slash = modelRef.indexOf("/") + if (slash <= 0) return + const local = yield* models + .resolve({ + ...session, + model: { + providerID: ProviderV2.ID.make(modelRef.slice(0, slash)), + id: ModelV2.ID.make(modelRef.slice(slash + 1)), + }, + }) + .pipe(Effect.option) + if (Option.isNone(local)) return + const chunks: string[] = [] + yield* llm + .stream( + LLM.request({ + model: local.value, + messages: [Message.user(Mask.ENTITY_PROMPT + text)], + tools: [], + generation: { maxTokens: 512 }, + }), + ) + .pipe( + Stream.runForEach((event) => { + if (LLMEvent.is.textDelta(event)) chunks.push(event.text) + return Effect.void + }), + Effect.catchTag("LLM.Error", () => Effect.void), + ) + for (const entity of Mask.parseEntities(chunks.join(""))) vault.add(entity) + }) const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) @@ -202,13 +260,16 @@ const layer = Layer.effect( const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id - const request = LLM.request({ + const privacy = yield* privacyService.get() + const vault = privacy.mode === "off" ? undefined : vaultFor(session.id) + const unmasked = LLM.request({ model, http: { headers: { "x-session-affinity": session.id, "X-Session-Id": session.id, ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}), + ...(APP_NAME ? { "x-redrob-app": APP_NAME } : {}), }, }, providerOptions: { openai: { promptCacheKey } }, @@ -219,6 +280,12 @@ const layer = Layer.effect( tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) + if (vault && privacy.mode === "model" && privacy.model) { + const fresh = vault.fresh(Mask.readableTexts(unmasked)) + if (fresh.length > 0) yield* learnEntities(session, vault, privacy.model, fresh.join("\n")) + } + const request = vault ? Mask.maskRequest(vault, unmasked) : unmasked + const unmaskEvent = vault ? Mask.unmaskEvents(vault) : undefined if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(continueAfterCompaction(currentStep)) const startSnapshot = yield* snapshots.capture() @@ -236,7 +303,11 @@ const layer = Layer.effect( const publish = (event: LLMEvent, outputPaths: ReadonlyArray = []) => withPublication(publisher.publish(event, outputPaths)) let overflowFailure: ProviderErrorEvent | undefined - const providerStream = llm.stream(request).pipe( + const providerStream = ( + unmaskEvent + ? llm.stream(request).pipe(Stream.flatMap((event) => Stream.fromIterable(unmaskEvent(event)))) + : llm.stream(request) + ).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return @@ -433,6 +504,7 @@ export const node = makeLocationNode({ SkillGuidance.node, ReferenceGuidance.node, Config.node, + Privacy.node, Snapshot.node, Database.node, ], diff --git a/packages/core/src/shared-account.ts b/packages/core/src/shared-account.ts new file mode 100644 index 0000000000..ce7821557d --- /dev/null +++ b/packages/core/src/shared-account.ts @@ -0,0 +1,94 @@ +export * as SharedAccount from "./shared-account" + +import os from "node:os" +import path from "node:path" +import fs from "node:fs/promises" + +/** + * Redrob Launcher's shared sign-in, read as this app's LAST fallback for the console key. + * + * The launcher signs a computer in to a workspace once and writes the key to one file every Redrob app + * may read. The contract is redrob-launcher `docs/shared-account.md`; the rules a reader follows: + * + * 1. Read, never write. Nothing here creates, rewrites or deletes the file. `DELETE /auth/redrob` removes + * the engine's own `auth.json` entry and nothing else, after which the shared file is the fallback again. + * 2. Read it when it is needed, not once at startup. The person may sign in or out of the launcher while + * the engine runs, so there is no cache here: every call hits the disk. + * 3. A missing file, unreadable JSON, a non-object, or an empty `apiKey` all mean "not signed in". + * 4. The key is never logged. Only `accountName` and `apiKeyName` are for showing to a person. + * 5. The app's own key wins. `REDROB_API_KEY`, the Credential store and `auth.json` are consulted first by + * every caller; this module is only ever the end of that chain. + */ + +export const FILE = "account.json" + +/** Replaces the directory on every platform when set and not empty (tests, portable setups). */ +export const DIR_ENV = "REDROB_SHARED_DIR" + +export interface Info { + readonly apiKey: string + readonly apiKeyName?: string + readonly accountName?: string + readonly apiBaseUrl?: string +} + +/** Where the launcher writes the file on this platform. Resolved on every call so the env override is live. */ +export function directory(): string { + const override = process.env[DIR_ENV] + if (override && override.trim()) return override + if (process.platform === "win32") { + const appdata = process.env["APPDATA"] || path.join(os.homedir(), "AppData", "Roaming") + return path.join(appdata, "Redrob", "Shared") + } + if (process.platform === "darwin") { + return path.join(os.homedir(), "Library", "Application Support", "ai.redrob.shared") + } + const xdg = process.env["XDG_CONFIG_HOME"] + const base = xdg && xdg.trim() ? xdg : path.join(os.homedir(), ".config") + return path.join(base, "redrob-shared") +} + +export function file(): string { + return path.join(directory(), FILE) +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined +} + +/** + * The file's text as the app's own shape. Every field is `unknown` on the way in because this is the + * boundary: a half-written or hand-edited file is not a key. + */ +export function parse(text: string): Info | undefined { + let raw: unknown + try { + raw = JSON.parse(text) + } catch { + return undefined + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined + const json = raw as Record + const apiKey = asString(json["apiKey"]) + if (!apiKey) return undefined + return { + apiKey, + apiKeyName: asString(json["apiKeyName"]), + accountName: asString(json["accountName"]), + apiBaseUrl: asString(json["apiBaseUrl"]), + } +} + +/** The launcher's sign-in right now; `undefined` when there is none. Never throws. */ +export async function read(): Promise { + try { + return parse(await fs.readFile(file(), "utf8")) + } catch { + return undefined + } +} + +/** The key alone, for callers that only need to spend it. */ +export async function apiKey(): Promise { + return (await read())?.apiKey +} diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index a0ffeae896..8c82d56a46 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -2,7 +2,7 @@ export * as SkillV2 from "./skill" import { makeLocationNode } from "./effect/app-node" import path from "path" -import { Context, Effect, Layer, Schema, Types } from "effect" +import { Context, Effect, Layer, Result, Schema, Types } from "effect" import { Skill } from "@redrob-code/schema/skill" import { AgentV2 } from "./agent" import { ConfigMarkdown } from "./config/markdown" @@ -27,6 +27,9 @@ export type Source = typeof Source.Type export const Info = Skill.Info export type Info = Skill.Info +export const AutoInject = Skill.AutoInject +export type AutoInject = Skill.AutoInject + export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") @@ -34,8 +37,25 @@ const Frontmatter = Schema.Struct({ name: Schema.String.pipe(Schema.optional), description: Schema.String.pipe(Schema.optional), slash: Schema.Boolean.pipe(Schema.optional), + icon: Schema.String.pipe(Schema.optional), }) -const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter) +/** + * `decodeUnknownResult`, not `decodeUnknownOption`: the Option form answers only "no", which + * makes a malformed document unfindable among the ones that loaded. The Result form carries + * the issue, so the log below can name the file AND why it was rejected. + */ +const decodeFrontmatter = Schema.decodeUnknownResult(Frontmatter) + +/** + * K-2's auto-arm block is decoded SEPARATELY from the rest of the frontmatter, so a + * malformed `autoInject` costs only the arming behaviour: the skill still loads and stays + * explicitly loadable, instead of the whole document being dropped for a typo in an + * optional block. + */ +const decodeAutoInject = Schema.decodeUnknownResult(Skill.AutoInject) + +/** The reason one document did not become a skill, kept so the log can say why. */ +type Drop = { readonly file: string; readonly reason: string } export type Data = { sources: Types.DeepMutable[] @@ -74,33 +94,82 @@ const layer = Layer.effect( const skills: Info[] = [] if (source.type === "embedded") return [source.skill] const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) + // Decoded ONE DOCUMENT AT A TIME: a malformed frontmatter costs that document and not + // the set, and every rejection is recorded with its reason so the cause is findable. + const drops: Drop[] = [] + let ignoredAutoInject = 0 for (const directory of directories) { const files = yield* fs .glob("{*.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 content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!content) continue + if (!content) { + drops.push({ file: filepath, reason: "unreadable: the file could not be read" }) + continue + } const markdown = ConfigMarkdown.parseOption(content) - if (!markdown) continue - const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined - if (!frontmatter) continue + if (!markdown) { + drops.push({ file: filepath, reason: "unparsable: no valid markdown frontmatter block" }) + continue + } + const decoded = decodeFrontmatter(markdown.data) + if (Result.isFailure(decoded)) { + drops.push({ file: filepath, reason: `frontmatter rejected: ${decoded.failure.message}` }) + continue + } + const frontmatter = decoded.success const name = frontmatter.name !== undefined ? frontmatter.name : path.dirname(filepath) === directory ? path.basename(filepath, ".md") : undefined - if (!name) continue + if (!name) { + drops.push({ + file: filepath, + reason: "unnamed: no `name` in frontmatter and the path gives no fallback name", + }) + continue + } + // A malformed `autoInject` is IGNORED, not fatal: the skill loads and stays + // explicitly loadable, which is what a skill without the block does anyway. + let autoInject: AutoInject | undefined + const declared = (markdown.data as Record | undefined)?.["autoInject"] + if (declared !== undefined) { + const block = decodeAutoInject(declared) + if (Result.isFailure(block)) { + ignoredAutoInject += 1 + // The reason goes in the MESSAGE, not only in an annotation: the default logger + // renders an annotation object as `[object Object]`, which loses exactly the + // detail that makes this findable. + yield* Effect.logWarning( + `SkillV2.load ignored a malformed autoInject block in ${filepath} (skill "${name}"): ${block.failure.message}`, + ) + } else autoInject = block.success + } skills.push({ name, description: frontmatter.description, slash: frontmatter.slash, + icon: frontmatter.icon, + autoInject, location: AbsolutePath.make(filepath), content: markdown.content, }) } } + if (drops.length > 0) { + // Warning, not debug: a dropped skill is silently missing behaviour, so the count and + // every individual reason have to reach a default log level to be findable at all. + // Both are in the message text for the same reason the per-block warning above is. + const total = skills.length + drops.length + yield* Effect.logWarning( + `SkillV2.load dropped ${drops.length} of ${total} skill documents from ${Source.key(source)} ` + + `(loaded ${skills.length}, ignoredAutoInject ${ignoredAutoInject}): ` + + drops.map((drop) => `${drop.file}: ${drop.reason}`).join("; "), + ) + } return skills }) diff --git a/packages/core/src/skill/arming.ts b/packages/core/src/skill/arming.ts new file mode 100644 index 0000000000..ca5e7a59e9 --- /dev/null +++ b/packages/core/src/skill/arming.ts @@ -0,0 +1,131 @@ +export * as SkillArming from "./arming" + +import type { Skill } from "@redrob-code/schema/skill" + +/** + * K-2: the arming decision, as a pure function. + * + * Given the loaded skills, the current prompt text and the current tab URL, `arm` returns + * which skills auto-arm. It reads nothing, caches nothing and logs nothing, so the decision + * is testable on its own and identical wherever it is asked: the session prompt builder, a + * UI preview, or a test. + * + * Two rules, both case-insensitive: + * + * - keyword: a keyword matches when it appears in the prompt on word boundaries, so `ai` + * does not match `said` but `docs.google.com` still matches when followed by a slash. + * - url: a glob matches the current tab URL in full. `*` and `?` stop at `/`; `**` crosses + * path segments. The scheme is ignored on both sides, so a glob is written + * `docs.google.com/document/**` rather than `https://docs.google.com/document/**`. + * + * A skill with no `autoInject` block NEVER auto-arms. It stays explicitly loadable, which + * is the point: a skill that arms on everything is a skill that is always in the prompt. + */ + +/** The shape `arm` needs from a skill. Narrower than `Skill.Info` so callers can test it directly. */ +export type Armable = { + readonly name: string + readonly autoInject?: Skill.AutoInject | undefined +} + +/** One skill that armed, with the patterns that armed it. */ +export type Armed = { + readonly name: string + /** Keywords from the skill's block that matched the prompt. */ + readonly keywords: ReadonlyArray + /** URL globs from the skill's block that matched the current tab URL. */ + readonly urls: ReadonlyArray +} + +export type Input = { + readonly skills: ReadonlyArray + /** The current prompt text. An empty prompt matches no keyword. */ + readonly prompt: string + /** The current tab URL, when the session is attached to one. Absent matches no glob. */ + readonly url?: string | undefined +} + +const isWordCharacter = (character: string | undefined): boolean => + character !== undefined && /[\p{L}\p{N}_]/u.test(character) + +/** + * Case-insensitive word-boundary containment. A boundary is required only where the + * keyword's own edge is a word character, so a keyword like `.pdf` or `docs.google.com` + * matches in running text while `ai` does not match inside `said`. + */ +const matchesKeyword = (prompt: string, keyword: string): boolean => { + const needle = keyword.trim().toLowerCase() + if (needle.length === 0) return false + const haystack = prompt.toLowerCase() + const checkStart = isWordCharacter(needle[0]) + const checkEnd = isWordCharacter(needle[needle.length - 1]) + let from = 0 + for (;;) { + const at = haystack.indexOf(needle, from) + if (at === -1) return false + const before = at === 0 ? undefined : haystack[at - 1] + const after = haystack[at + needle.length] + if ((!checkStart || !isWordCharacter(before)) && (!checkEnd || !isWordCharacter(after))) return true + from = at + 1 + } +} + +/** Drops a `scheme://` prefix and a trailing slash, so globs are written without a scheme. */ +const normalizeUrl = (value: string): string => + value + .trim() + .toLowerCase() + .replace(/^[a-z][a-z0-9+.-]*:\/\//, "") + .replace(/\/+$/, "") + +/** + * Compiles a glob to an anchored regular expression. `**` crosses `/`; `*` and `?` do not. + * Every other character is matched literally, so a `.` in a hostname is a dot and not a + * wildcard - the common authoring mistake if globs were treated as regular expressions. + */ +const globToRegExp = (glob: string): RegExp => { + let pattern = "" + for (let index = 0; index < glob.length; index += 1) { + const character = glob[index]! + if (character === "*") { + if (glob[index + 1] === "*") { + pattern += ".*" + index += 1 + continue + } + pattern += "[^/]*" + continue + } + if (character === "?") { + pattern += "[^/]" + continue + } + pattern += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + } + return new RegExp(`^${pattern}$`) +} + +const matchesUrlGlob = (url: string, glob: string): boolean => { + const normalized = normalizeUrl(glob) + if (normalized.length === 0) return false + return globToRegExp(normalized).test(normalizeUrl(url)) +} + +/** Returns the skills that auto-arm, in the order they were given. */ +export const arm = (input: Input): ReadonlyArray => { + const armed: Array = [] + for (const skill of input.skills) { + const block = skill.autoInject + // No block means never auto-arm. This is the explicit-only case, not a default-on one. + if (block === undefined) continue + const keywords = (block.keywords ?? []).filter((keyword) => matchesKeyword(input.prompt, keyword)) + const urls = + input.url === undefined ? [] : (block.url ?? []).filter((glob) => matchesUrlGlob(input.url as string, glob)) + if (keywords.length === 0 && urls.length === 0) continue + armed.push({ name: skill.name, keywords, urls }) + } + return armed +} + +/** Just the names that armed, for callers that only need the set. */ +export const armedNames = (input: Input): ReadonlyArray => arm(input).map((entry) => entry.name) diff --git a/packages/core/test/browser-request.test.ts b/packages/core/test/browser-request.test.ts new file mode 100644 index 0000000000..87518a9cbc --- /dev/null +++ b/packages/core/test/browser-request.test.ts @@ -0,0 +1,143 @@ +import { describe, expect } from "bun:test" +import { Cause, Deferred, Duration, Effect, Exit, Fiber } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { LayerNode } from "@redrob-code/core/effect/layer-node" +import { AppNodeBuilder } from "@redrob-code/core/effect/app-node-builder" +import { EventV2 } from "@redrob-code/core/event" +import { BrowserRequestV1 } from "@redrob-code/core/browser-request" +import { SessionV2 } from "@redrob-code/core/session" +import { testEffect } from "./lib/effect" + +const browser = AppNodeBuilder.build(LayerNode.group([EventV2.node, BrowserRequestV1.node])) +const it = testEffect(browser) + +const sessionID = SessionV2.ID.make("ses_browser_test") +const command: BrowserRequestV1.Command = { action: "page.text" } + +/** Forks an ask and returns once its `asked` event has been observed on the stream. */ +const waitForAsk = Effect.fn("BrowserRequestV1Test.waitForAsk")(function* ( + service: BrowserRequestV1.Interface, + input: BrowserRequestV1.AskInput, +) { + const events = yield* EventV2.Service + const asked = yield* Deferred.make() + const unsubscribe = yield* events.listen((event) => + event.type === BrowserRequestV1.Event.Asked.type + ? Deferred.succeed(asked, event.data as BrowserRequestV1.Request).pipe(Effect.asVoid) + : Effect.void, + ) + yield* Effect.addFinalizer(() => unsubscribe) + const fiber = yield* service.ask(input).pipe(Effect.forkScoped) + return { fiber, request: yield* Deferred.await(asked) } +}) + +describe("BrowserRequestV1", () => { + it.effect("publishes the action on the event stream and settles on a reply", () => + Effect.gen(function* () { + const service = yield* BrowserRequestV1.Service + const events = yield* EventV2.Service + const published: EventV2.Payload[] = [] + const unsubscribe = yield* events.listen((event) => + Effect.sync(() => { + if (event.type.startsWith("browser.request.")) published.push(event) + }), + ) + yield* Effect.addFinalizer(() => unsubscribe) + const { fiber, request } = yield* waitForAsk(service, { sessionID, command }) + + expect(request.id).toMatch(/^brq_/) + // The command travels whole: a client that only got the action name could not act. + expect(request.command).toEqual(command) + expect(yield* service.list()).toEqual([request]) + + yield* service.reply({ requestID: request.id, value: { type: "string", value: "page body" } }) + + expect(yield* Fiber.join(fiber)).toEqual({ type: "string", value: "page body" }) + expect(yield* service.list()).toEqual([]) + expect(published.map((event) => event.type)).toEqual([ + BrowserRequestV1.Event.Asked.type, + BrowserRequestV1.Event.Answered.type, + ]) + }), + ) + + it.effect("a refusal fails the ask with the client's own reason", () => + Effect.gen(function* () { + const service = yield* BrowserRequestV1.Service + const { fiber, request } = yield* waitForAsk(service, { + sessionID, + command: { action: "page.click", selector: "#buy" }, + }) + + yield* service.refuse({ requestID: request.id, reason: "that tab is not shared with the agent" }) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + expect(error).toBeInstanceOf(BrowserRequestV1.RefusedError) + expect((error as BrowserRequestV1.RefusedError).message).toBe( + "the browser refused page.click: that tab is not shared with the agent", + ) + // Settled either way: a refused request must not keep occupying the pending map. + expect(yield* service.list()).toEqual([]) + }), + ) + + it.effect("settling an unknown id is refused rather than ignored", () => + Effect.gen(function* () { + const service = yield* BrowserRequestV1.Service + const unknown = BrowserRequestV1.ID.make("brq_unknown") + + const replied = yield* service + .reply({ requestID: unknown, value: { type: "void" } }) + .pipe(Effect.exit) + const refused = yield* service.refuse({ requestID: unknown, reason: "whatever" }).pipe(Effect.exit) + + expect(Exit.isFailure(replied)).toBe(true) + expect(Exit.isFailure(refused)).toBe(true) + }), + ) + + /** + * The deadline is the reason this service can be used by a skill at all. + * + * Nothing tells the engine whether a client is subscribed — the event stream has no + * subscriber count — so "no browser attached" is indistinguishable from "the browser is + * slow". Waiting forever would hang the session of anyone who calls `page.text()` with no + * browser running, which is the single outcome that is certainly wrong. + * + * Driven by the TestClock, so this asserts the real `DEADLINE` constant without the suite + * paying twenty seconds of wall clock for it. + */ + it.effect("an unanswered request fails on the deadline, naming the action", () => + Effect.gen(function* () { + const service = yield* BrowserRequestV1.Service + const fiber = yield* service.ask({ sessionID, command }).pipe(Effect.forkScoped) + + yield* TestClock.adjust(Duration.sum(BrowserRequestV1.DEADLINE, Duration.seconds(1))) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + expect(error).toBeInstanceOf(BrowserRequestV1.UnansweredError) + expect((error as BrowserRequestV1.UnansweredError).action).toBe("page.text") + expect((error as BrowserRequestV1.UnansweredError).message).toContain("no browser client answered page.text") + // And it is no longer pending: a request nobody answered must not leak. + expect(yield* service.list()).toEqual([]) + }), + ) + + it.effect("a request answered before the deadline does NOT fail", () => + Effect.gen(function* () { + // The other half of the deadline test. A timeout that fires regardless would pass the + // test above and break every real call. + const service = yield* BrowserRequestV1.Service + const { fiber, request } = yield* waitForAsk(service, { sessionID, command }) + + yield* service.reply({ requestID: request.id, value: { type: "void" } }) + yield* TestClock.adjust(Duration.sum(BrowserRequestV1.DEADLINE, Duration.seconds(1))) + + expect(yield* Fiber.join(fiber)).toEqual({ type: "void" }) + }), + ) +}) diff --git a/packages/core/test/config/local-gpu-probe.test.ts b/packages/core/test/config/local-gpu-probe.test.ts new file mode 100644 index 0000000000..d02534dd7a --- /dev/null +++ b/packages/core/test/config/local-gpu-probe.test.ts @@ -0,0 +1,88 @@ +/** + * PA-8's GPU parsers, against recorded tool output. + * + * Parsed, not mocked: each fixture is the shape the real command prints, with the flags the + * probe passes. A parser verified only against a string I also wrote would prove the two + * agree and nothing about the tool. + * + * The failure these guard is silent. A parser that returns `undefined` for output it should + * have read makes the machine look like one with no GPU, which is a false claim this module + * exists specifically to avoid. + */ +import { describe, expect, test } from "bun:test" +import { LocalGpuProbe } from "@redrob-code/core/config/plugin/local-gpu-probe" + +const MiB = 1024 * 1024 + +const probeFor = (command: string) => { + const found = LocalGpuProbe.PROBES.find((candidate) => candidate.command === command) + if (found === undefined) throw new Error(`no probe spawns ${command}`) + return found +} + +describe("nvidia-smi", () => { + const nvidia = probeFor("nvidia-smi") + + test("passes the flags that make the output machine-readable", () => { + // `noheader,nounits` is what lets the parse be a comma split instead of a column guess, + // and `nounits` is why the memory field is read as plain MiB. + expect(nvidia.args.join(" ")).toContain("--format=csv,noheader,nounits") + expect(nvidia.args.join(" ")).toContain("memory.total") + }) + + test("reads name and VRAM from one card", () => { + expect(nvidia.parse("NVIDIA GeForce RTX 4090, 24564\n")).toEqual({ + name: "NVIDIA GeForce RTX 4090", + vramBytes: 24564 * MiB, + }) + }) + + test("takes the first card when several are listed", () => { + const output = "NVIDIA A100-SXM4-40GB, 40960\nNVIDIA A100-SXM4-40GB, 40960\n" + expect(nvidia.parse(output)?.name).toBe("NVIDIA A100-SXM4-40GB") + }) + + test("keeps the name when the memory field is unreadable", () => { + // A driver that reports `[N/A]` for memory still tells us the card exists, and a card we + // know about with a size we do not is better than pretending there is no card. + const parsed = nvidia.parse("NVIDIA GeForce GTX 1060, [N/A]\n") + expect(parsed?.name).toBe("NVIDIA GeForce GTX 1060") + expect(parsed?.vramBytes).toBeUndefined() + }) + + test("returns undefined for empty output rather than an empty name", () => { + expect(nvidia.parse("")).toBeUndefined() + expect(nvidia.parse("\n\n")).toBeUndefined() + }) +}) + +describe("apple silicon", () => { + const sysctl = probeFor("sysctl") + + test("names the chip and reports no separate VRAM", () => { + // Unified memory: the GPU uses the same bytes the memory check already counted, so a + // VRAM number here would be those bytes counted twice. + expect(sysctl.parse("Apple M3 Max\n")).toEqual({ name: "Apple M3 Max (unified memory)", vramBytes: undefined }) + }) + + test("declines an Intel Mac rather than calling its CPU a GPU", () => { + expect(sysctl.parse("Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz\n")).toBeUndefined() + }) + + test("is offered only on darwin", () => { + expect(sysctl.platforms).toEqual(["darwin"]) + }) +}) + +describe("probe ordering", () => { + test("nvidia is tried before the platform-specific fallback on darwin", () => { + // An external or eGPU NVIDIA card is the more specific answer, and the sysctl probe + // would otherwise claim unified memory on a machine that has a discrete card. + const darwin = LocalGpuProbe.PROBES.filter((candidate) => candidate.platforms.includes("darwin")) + expect(darwin[0]?.command).toBe("nvidia-smi") + }) + + test("every probe declares the platforms it applies to", () => { + for (const candidate of LocalGpuProbe.PROBES) expect(candidate.platforms.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/core/test/config/local-model-fit.test.ts b/packages/core/test/config/local-model-fit.test.ts new file mode 100644 index 0000000000..136f9b92f4 --- /dev/null +++ b/packages/core/test/config/local-model-fit.test.ts @@ -0,0 +1,129 @@ +/** + * PA-8's fit judgement. + * + * Every case here is a claim the user could push back on, so each asserts the NUMBERS in the + * refusal rather than that a refusal happened. A verdict with no numbers is unfalsifiable by + * the person it is about, which is the opposite of honest. + */ +import { describe, expect, test } from "bun:test" +import { LocalModelFit } from "@redrob-code/core/config/plugin/local-model-fit" + +const GiB = 1024 ** 3 + +const machine = (overrides: Partial = {}): LocalModelFit.Hardware => ({ + platform: "linux", + arch: "x64", + cores: 8, + totalBytes: 16 * GiB, + availableBytes: 12 * GiB, + ...overrides, +}) + +describe("LocalModelFit.requirementFor", () => { + test("adds proportional headroom above the floor", () => { + // 10 GiB of weights: 20% is 2 GiB, which clears the 1 GiB floor, so the floor does not apply. + expect(LocalModelFit.requirementFor(10 * GiB)).toBe(12 * GiB) + }) + + test("applies the floor when the proportion is smaller than it", () => { + // 2 GiB of weights: 20% is 0.4 GiB, under the floor, so the floor is what is added. + expect(LocalModelFit.requirementFor(2 * GiB)).toBe(3 * GiB) + }) + + test("the headroom is never zero, so a model is never sized at exactly its weights", () => { + expect(LocalModelFit.requirementFor(0)).toBe(LocalModelFit.HEADROOM_FLOOR_BYTES) + }) +}) + +describe("LocalModelFit.fit", () => { + test("fits when available memory covers weights plus headroom", () => { + const verdict = LocalModelFit.fit({ modelBytes: 4 * GiB, hardware: machine() }) + expect(verdict.kind).toBe("fits") + }) + + test("is short by the exact difference, and says closing something would do it", () => { + // Needs 12 GiB (10 + 2 headroom); 8 free of 16 total, so freeing 4 GiB is enough. + const verdict = LocalModelFit.fit({ + modelBytes: 10 * GiB, + hardware: machine({ availableBytes: 8 * GiB, totalBytes: 16 * GiB }), + }) + if (verdict.kind !== "short") throw new Error("expected a refusal") + expect(verdict.constraint).toBe("memory") + expect(verdict.requiredBytes).toBe(12 * GiB) + expect(verdict.shortfallBytes).toBe(4 * GiB) + expect(verdict.reason).toContain("12.0 GiB") + expect(verdict.reason).toContain("8.0 GiB is free of 16.0 GiB total") + expect(verdict.reason).toContain("if you close something") + }) + + test("says the machine is too small when freeing everything would not do it", () => { + // Needs 12 GiB and the machine HAS 8 GiB. No amount of closing windows fixes this, and + // telling the user to try would waste their time. + const verdict = LocalModelFit.fit({ + modelBytes: 10 * GiB, + hardware: machine({ availableBytes: 6 * GiB, totalBytes: 8 * GiB }), + }) + if (verdict.kind !== "short") throw new Error("expected a refusal") + expect(verdict.reason).toContain("more than this machine has in total") + expect(verdict.reason).not.toContain("if you close something") + }) + + test("checks available rather than total, so a busy large machine is refused", () => { + // 64 GiB machine with 2 GiB free. Checking total would promise a model that gets killed + // partway through generating, which loses the user's work and explains nothing. + const verdict = LocalModelFit.fit({ + modelBytes: 10 * GiB, + hardware: machine({ availableBytes: 2 * GiB, totalBytes: 64 * GiB }), + }) + expect(verdict.kind).toBe("short") + }) + + test("refuses an unsupported CPU before talking about memory at all", () => { + // A machine with plenty of memory and no runtime build. Reporting a memory shortfall + // here would send the user to close applications for a problem that is not memory. + const verdict = LocalModelFit.fit({ + modelBytes: 1 * GiB, + hardware: machine({ arch: "ppc64", availableBytes: 60 * GiB, totalBytes: 64 * GiB }), + }) + if (verdict.kind !== "short") throw new Error("expected a refusal") + expect(verdict.constraint).toBe("arch") + expect(verdict.reason).toContain("ppc64") + }) + + test("an unseen GPU is reported as unmeasured, never as absent", () => { + const verdict = LocalModelFit.fit({ modelBytes: 1 * GiB, hardware: machine({ gpu: undefined }) }) + expect(verdict.hardware.gpu).toBeUndefined() + // The refusal text must never make a claim about a GPU we did not look at. + const refused = LocalModelFit.fit({ + modelBytes: 100 * GiB, + hardware: machine({ gpu: undefined }), + }) + if (refused.kind !== "short") throw new Error("expected a refusal") + expect(refused.reason.toLowerCase()).not.toContain("gpu") + expect(refused.reason.toLowerCase()).not.toContain("graphics") + }) + + test("carries a measured GPU through untouched", () => { + const gpu = { name: "Test GPU", vramBytes: 8 * GiB } + const verdict = LocalModelFit.fit({ modelBytes: 1 * GiB, hardware: machine({ gpu }) }) + expect(verdict.hardware.gpu).toEqual(gpu) + }) +}) + +describe("LocalModelFit.measure", () => { + test("reports this machine's real numbers, and no GPU claim without a probe", () => { + const hardware = LocalModelFit.measure() + expect(hardware.totalBytes).toBeGreaterThan(0) + expect(hardware.availableBytes).toBeGreaterThan(0) + expect(hardware.availableBytes).toBeLessThanOrEqual(hardware.totalBytes) + expect(hardware.cores).toBeGreaterThan(0) + expect(hardware.gpu).toBeUndefined() + }) + + test("takes the GPU from its caller rather than probing inside the verdict path", () => { + // The split exists so the pure judgement is testable with no machine. If `measure` ever + // probed on its own, this test would see a GPU it never passed in. + const hardware = LocalModelFit.measure({ name: "Passed In" }) + expect(hardware.gpu?.name).toBe("Passed In") + }) +}) diff --git a/packages/core/test/config/local-model-pull.test.ts b/packages/core/test/config/local-model-pull.test.ts new file mode 100644 index 0000000000..3dc7ae181a --- /dev/null +++ b/packages/core/test/config/local-model-pull.test.ts @@ -0,0 +1,177 @@ +/** + * PA-8's pull and listing, against the runtime's OWN published examples. + * + * Every fixture below is copied from the runtime's API reference, not written to match this + * code. That distinction is the whole value: a fixture and a parser written from one reading + * of a schema agree with each other and prove nothing about the real producer. + * + * Two traps the reference states explicitly, and both have a test: + * - `completed` MAY BE ABSENT while a layer is downloading ("Until any of the download is + * completed, the `completed` key may not be included"). + * - `total` is per-LAYER, not per-model ("The number of files to be downloaded depends on + * the number of layers specified in the manifest"). + */ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { LocalModelFit } from "@redrob-code/core/config/plugin/local-model-fit" +import { LocalModelPull } from "@redrob-code/core/config/plugin/local-model-pull" + +describe("urls", () => { + test("join without doubling a slash", () => { + expect(LocalModelPull.tagsUrl("http://127.0.0.1:11434")).toBe("http://127.0.0.1:11434/api/tags") + expect(LocalModelPull.tagsUrl("http://127.0.0.1:11434/")).toBe("http://127.0.0.1:11434/api/tags") + expect(LocalModelPull.pullUrl("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434/api/pull") + }) + + test("the runtime API is a sibling of the OpenAI base, not a child of it", () => { + // A local runtime is configured with its OpenAI base, conventionally `/v1` -- that is the + // URL the provider needs and the one a user copies out of the runtime's own banner. But + // `/api/tags` sits beside `/v1`, not under it. + // + // This is a REGRESSION TEST for a defect only an end-to-end run exposed: the first version + // joined onto the configured base, produced `/v1/api/tags`, got a 404, and the command + // printed "answering, with nothing installed" against a server that was serving two + // models. A wrong statement that reads as a working feature, because a 404 and an empty + // list are both "no models" to a caller that does not separate them. + expect(LocalModelPull.tagsUrl("http://127.0.0.1:11434/v1")).toBe("http://127.0.0.1:11434/api/tags") + expect(LocalModelPull.pullUrl("http://127.0.0.1:11434/v1/")).toBe("http://127.0.0.1:11434/api/pull") + // Any version segment, not just v1: some runtimes serve /v2. + expect(LocalModelPull.tagsUrl("http://127.0.0.1:11434/v2")).toBe("http://127.0.0.1:11434/api/tags") + }) + + test("a path that merely CONTAINS a version segment is left alone", () => { + // Only a TRAILING version segment is the OpenAI base. Stripping `/v1` from the middle of a + // path would break a runtime served under a prefix by a reverse proxy. + expect(LocalModelPull.tagsUrl("http://127.0.0.1:11434/v1/engine")).toBe( + "http://127.0.0.1:11434/v1/engine/api/tags", + ) + }) +}) + +describe("readFrames", () => { + // Verbatim from the reference's own pull example, in order. + const stream = [ + '{"status":"pulling manifest"}', + '{"status":"pulling digestname","digest":"digestname","total":2142590208,"completed":241970}', + '{"status":"verifying sha256 digest"}', + '{"status":"writing manifest"}', + '{"status":"removing any unused layers"}', + '{"status":"success"}', + ].join("\n") + + test("reads every frame of the documented stream", () => { + const frames = LocalModelPull.readFrames(stream) + expect(frames.length).toBe(6) + expect(frames[0]?.status).toBe("pulling manifest") + expect(frames[1]?.total).toBe(2142590208) + expect(frames[5]?.status).toBe("success") + }) + + test("status-only frames decode, carrying no byte counts", () => { + const frames = LocalModelPull.readFrames('{"status":"writing manifest"}') + expect(frames[0]?.total).toBeUndefined() + expect(frames[0]?.completed).toBeUndefined() + }) + + test("a downloading frame with no completed key is still a frame", () => { + // The reference says this one happens before the first bytes land. Requiring `completed` + // would drop the frame that announces the size of the download. + const frames = LocalModelPull.readFrames('{"status":"pulling abc","digest":"abc","total":2142590208}') + expect(frames.length).toBe(1) + expect(frames[0]?.total).toBe(2142590208) + expect(frames[0]?.completed).toBeUndefined() + }) + + test("one unreadable line costs that line, not the transfer", () => { + const frames = LocalModelPull.readFrames( + ['{"status":"pulling manifest"}', "{ not json", '{"status":"success"}'].join("\n"), + ) + expect(frames.map((frame) => frame.status)).toEqual(["pulling manifest", "success"]) + }) + + test("a whole-body JSON parse would have failed on this stream", () => { + // Guarding the reason the line-by-line reader exists: the runtime does not wrap the + // frames in an array, so anyone "simplifying" this to one parse breaks every pull. + expect(() => JSON.parse(stream)).toThrow() + }) +}) + +describe("readFrame", () => { + test("marks only the success status as done", () => { + const progress = LocalModelPull.readFrame({ status: "pulling manifest" }) + expect("error" in progress ? undefined : progress.done).toBe(false) + const finished = LocalModelPull.readFrame({ status: "success" }) + expect("error" in finished ? undefined : finished.done).toBe(true) + }) + + test("reports layer bytes as layer bytes", () => { + const progress = LocalModelPull.readFrame({ status: "pulling abc", total: 100, completed: 40 }) + if ("error" in progress) throw new Error("unexpected error frame") + // Named for what they are. A caller that read these as whole-model progress would show a + // bar that jumps back to zero on every layer of a multi-layer model. + expect(progress.layerTotalBytes).toBe(100) + expect(progress.layerCompletedBytes).toBe(40) + }) + + test("an in-band error is terminal, not progress", () => { + // A 200 is already sent by the time this arrives. Treated as progress, the pull would + // look like it is still running and never finish. + const progress = LocalModelPull.readFrame({ status: "error", error: "model not found" }) + expect(progress).toEqual({ error: "model not found" }) + }) + + test("an empty error string is not an error", () => { + const progress = LocalModelPull.readFrame({ status: "success", error: "" }) + expect("error" in progress).toBe(false) + }) + + test("passes the runtime's status through rather than re-wording it", () => { + const progress = LocalModelPull.readFrame({ status: "removing any unused layers" }) + if ("error" in progress) throw new Error("unexpected error frame") + expect(progress.status).toBe("removing any unused layers") + }) +}) + +describe("Installed", () => { + // Verbatim from the reference's `GET /api/tags` example. + const entry = { + name: "deepseek-r1:latest", + model: "deepseek-r1:latest", + modified_at: "2025-05-10T08:06:48.639712648-07:00", + size: 4683075271, + digest: "0a8c266910232fd3291e71e5ba1e058cc5af9d411192cf88b6d30e92b6e73163", + details: { format: "gguf", family: "qwen2", parameter_size: "7.6B", quantization_level: "Q4_K_M" }, + } + + test("decodes through the real schema, keeping the size the fit check needs", () => { + const decoded = Schema.decodeUnknownSync(LocalModelPull.Installed)(entry) + expect(decoded.name).toBe("deepseek-r1:latest") + expect(decoded.size).toBe(4683075271) + expect(decoded.digest).toBe(entry.digest) + }) + + test("the size feeds the fit check as bytes, not as anything else", () => { + // 4.68 GB of weights, so the requirement is that plus headroom -- which is what makes a + // 4 GiB machine the wrong machine for it. The two modules meet exactly here. + const decoded = Schema.decodeUnknownSync(LocalModelPull.Installed)(entry) + const verdict = LocalModelFit.fit({ + modelBytes: decoded.size, + hardware: { + platform: "linux", + arch: "x64", + cores: 8, + totalBytes: 4 * 1024 ** 3, + availableBytes: 3 * 1024 ** 3, + }, + }) + if (verdict.kind !== "short") throw new Error("a 4 GiB machine should not fit a 4.68 GB model") + expect(verdict.constraint).toBe("memory") + expect(verdict.requiredBytes).toBe(LocalModelFit.requirementFor(4683075271)) + }) + + test("an entry with no size is dropped rather than treated as free", () => { + // A model of unknown size that defaulted to zero would pass every fit check and then be + // killed mid-load. Refusing to decode it is the honest outcome. + expect(Schema.decodeUnknownOption(LocalModelPull.Installed)({ name: "x" })._tag).toBe("None") + }) +}) diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 3c159c9178..4cfec483d8 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -10,6 +10,7 @@ import { SkillV2 } from "@redrob-code/core/skill" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { host } from "../plugin/host" +import { CONSOLE_SKILLS_URL } from "@redrob-code/core/plugin/provider/redrob-constants" const it = testEffect(Layer.empty) const decode = Schema.decodeUnknownSync(Config.Info) @@ -67,6 +68,8 @@ describe("ConfigSkillPlugin.Plugin", () => { type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skills")), }), + // The Console's library comes first, before anything the user configured. + SkillV2.UrlSource.make({ type: "url", url: CONSOLE_SKILLS_URL }), SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), SkillV2.DirectorySource.make({ type: "directory", diff --git a/packages/core/test/console-key.test.ts b/packages/core/test/console-key.test.ts new file mode 100644 index 0000000000..17b86b0851 --- /dev/null +++ b/packages/core/test/console-key.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Effect } from "effect" +import { Credential } from "@redrob-code/core/credential" +import { AppNodeBuilder } from "@redrob-code/core/effect/app-node-builder" +import { LayerNode } from "@redrob-code/core/effect/layer-node" +import { EventV2 } from "@redrob-code/core/event" +import { Global } from "@redrob-code/core/global" +import { Integration } from "@redrob-code/core/integration" +import { ConsoleKey } from "@redrob-code/core/console-key" +import { SharedAccount } from "@redrob-code/core/shared-account" +import { testEffect } from "./lib/effect" + +/** + * Redrob Launcher's shared `account.json` as the LAST fallback for the console key. + * + * Everything here points `REDROB_SHARED_DIR` at a scratch directory, so no test ever reads or writes + * the real `~/.config/redrob-shared`. `auth.json` lives under `Global.Path.data`, which the preload has + * already moved into a temp directory for the same reason. + */ + +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node]))) + +const authPath = path.join(Global.Path.data, "auth.json") + +let sharedDir: string +let savedEnvKey: string | undefined +let savedSharedDir: string | undefined + +const sharedFile = () => path.join(sharedDir, SharedAccount.FILE) + +const writeShared = (body: unknown) => + fs.writeFile(sharedFile(), typeof body === "string" ? body : JSON.stringify(body)) + +const launcherAccount = { + apiKey: "rk_from_launcher", + apiKeyName: "Redrob Launcher", + accountName: "Acme", + apiBaseUrl: "https://console.redrob.ai/api/backend/v1", +} + +beforeEach(async () => { + sharedDir = await fs.mkdtemp(path.join(os.tmpdir(), "redrob-shared-test-")) + savedEnvKey = process.env["REDROB_API_KEY"] + savedSharedDir = process.env[SharedAccount.DIR_ENV] + delete process.env["REDROB_API_KEY"] + process.env[SharedAccount.DIR_ENV] = sharedDir + await fs.rm(authPath, { force: true }) +}) + +afterEach(async () => { + if (savedEnvKey === undefined) delete process.env["REDROB_API_KEY"] + else process.env["REDROB_API_KEY"] = savedEnvKey + if (savedSharedDir === undefined) delete process.env[SharedAccount.DIR_ENV] + else process.env[SharedAccount.DIR_ENV] = savedSharedDir + await fs.rm(authPath, { force: true }) + await fs.rm(sharedDir, { recursive: true, force: true }) +}) + +describe("SharedAccount", () => { + test("REDROB_SHARED_DIR replaces the directory, and the file name stays account.json", () => { + expect(SharedAccount.directory()).toBe(sharedDir) + expect(SharedAccount.file()).toBe(path.join(sharedDir, "account.json")) + }) + + test("a blank REDROB_SHARED_DIR is not an override", () => { + process.env[SharedAccount.DIR_ENV] = " " + expect(SharedAccount.directory()).not.toBe(" ") + expect(SharedAccount.directory().endsWith(process.platform === "win32" ? "Shared" : "redrob-shared")).toBe(true) + }) + + test("parses the launcher's shape and drops blanks", () => { + expect(SharedAccount.parse(JSON.stringify(launcherAccount))).toEqual(launcherAccount) + expect(SharedAccount.parse(JSON.stringify({ apiKey: "rk_x", accountName: " " }))).toEqual({ + apiKey: "rk_x", + apiKeyName: undefined, + accountName: undefined, + apiBaseUrl: undefined, + }) + }) + + test("an empty apiKey, a non-object, or unreadable JSON all mean not signed in", () => { + expect(SharedAccount.parse(JSON.stringify({ apiKey: "" }))).toBeUndefined() + expect(SharedAccount.parse(JSON.stringify({ apiKey: " " }))).toBeUndefined() + expect(SharedAccount.parse(JSON.stringify({ apiKeyName: "Redrob Launcher" }))).toBeUndefined() + expect(SharedAccount.parse(JSON.stringify([launcherAccount]))).toBeUndefined() + expect(SharedAccount.parse("null")).toBeUndefined() + expect(SharedAccount.parse('"rk_not_an_object"')).toBeUndefined() + expect(SharedAccount.parse("{ not json")).toBeUndefined() + expect(SharedAccount.parse("")).toBeUndefined() + }) + + test("a missing file reads as not signed in rather than an error", async () => { + expect(await SharedAccount.read()).toBeUndefined() + expect(await SharedAccount.apiKey()).toBeUndefined() + }) + + test("reads the file fresh every time: a launcher sign-out is seen on the next read", async () => { + await writeShared(launcherAccount) + expect(await SharedAccount.apiKey()).toBe("rk_from_launcher") + await fs.rm(sharedFile()) + expect(await SharedAccount.apiKey()).toBeUndefined() + await writeShared({ ...launcherAccount, apiKey: "rk_second_sign_in" }) + expect(await SharedAccount.apiKey()).toBe("rk_second_sign_in") + }) +}) + +describe("ConsoleKey with the shared account as the last fallback", () => { + it.effect("resolves nothing when no source holds a key", () => + Effect.gen(function* () { + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + expect(yield* ConsoleKey.resolve()).toBeUndefined() + }), + ) + + it.effect("falls back to the launcher's shared file and says so", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + expect(yield* ConsoleKey.resolveWithSource()).toEqual({ key: "rk_from_launcher", source: "launcher" }) + expect(yield* ConsoleKey.resolve()).toBe("rk_from_launcher") + }), + ) + + it.effect("REDROB_API_KEY wins over the shared file", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + process.env["REDROB_API_KEY"] = "rk_from_env" + expect(yield* ConsoleKey.resolveWithSource()).toEqual({ key: "rk_from_env", source: "env" }) + }), + ) + + it.effect("the app's own auth.json wins over the shared file", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + yield* Effect.promise(() => + fs.writeFile(authPath, JSON.stringify({ redrob: { type: "api", key: "rk_from_auth_json" } })), + ) + expect(yield* ConsoleKey.resolveWithSource()).toEqual({ key: "rk_from_auth_json", source: "api" }) + }), + ) + + it.effect("deleting auth.json leaves the shared file alone and falls back to it", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + yield* Effect.promise(() => + fs.writeFile(authPath, JSON.stringify({ redrob: { type: "api", key: "rk_from_auth_json" } })), + ) + expect((yield* ConsoleKey.resolveWithSource())?.source).toBe("api") + // What `DELETE /auth/redrob` does to the engine's own store. The shared file is not ours to touch. + yield* Effect.promise(() => fs.rm(authPath)) + expect(yield* ConsoleKey.resolveWithSource()).toEqual({ key: "rk_from_launcher", source: "launcher" }) + expect(yield* Effect.promise(() => fs.readFile(sharedFile(), "utf8"))).toBe(JSON.stringify(launcherAccount)) + }), + ) + + it.effect("a malformed or empty shared file is not signed in", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared("{ half a file")) + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + yield* Effect.promise(() => writeShared({ apiKey: "" })) + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + yield* Effect.promise(() => writeShared("")) + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + }), + ) + + it.effect("a file removed between two resolves is seen as gone: nothing is cached", () => + Effect.gen(function* () { + yield* Effect.promise(() => writeShared(launcherAccount)) + expect((yield* ConsoleKey.resolveWithSource())?.source).toBe("launcher") + yield* Effect.promise(() => fs.rm(sharedFile())) + expect(yield* ConsoleKey.resolveWithSource()).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 76fa346747..e1076dbb01 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -8,7 +8,12 @@ import { ProviderV2 } from "@redrob-code/core/provider" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@redrob-labs/sdk/v2/types" import { Effect } from "effect" -type Overrides = Partial> +/** + * `skill` is widened because tests hand in the core SkillV2 service directly, whose arrays are + * readonly, while the plugin API is typed from the generated SDK, which has no readonly arrays. + * The real host converts at the boundary (plugin/host.ts `mutable`); here a cast is enough. + */ +type Overrides = Partial> & { skill?: unknown } export function host(overrides: Overrides = {}): PluginContext { return { @@ -45,7 +50,7 @@ export function host(overrides: Overrides = {}): PluginContext { transform: () => Effect.die("unused reference.transform"), reload: () => Effect.die("unused reference.reload"), }, - skill: overrides.skill ?? { + skill: (overrides.skill as PluginContext["skill"] | undefined) ?? { transform: () => Effect.die("unused skill.transform"), reload: () => Effect.die("unused skill.reload"), }, diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts index 6d945b0282..acf921b93e 100644 --- a/packages/core/test/preload.ts +++ b/packages/core/test/preload.ts @@ -23,6 +23,15 @@ import path from "path" const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "redrob-core-test-data-")) process.env.XDG_DATA_HOME = dataDir +/* + The same leak, one source further down the chain. `resolveApiKey()` now ends at Redrob Launcher's shared + `account.json` (`~/.config/redrob-shared`), and a developer whose launcher is signed in would otherwise + hand the "no key" tests a real credential from there. `REDROB_SHARED_DIR` is the override the contract + defines for exactly this, so point it at an empty directory; the suites that exercise the fallback set + it to their own scratch directory per test. +*/ +process.env.REDROB_SHARED_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "redrob-core-test-shared-")) + process.env.REDROB_DB = ":memory:" // Gate the console /models fetch off so core tests stay offline/deterministic; the // ModelsDev.Service then serves the static console fallback catalog (redrob/auto and the rest of diff --git a/packages/core/test/privacy/mask.test.ts b/packages/core/test/privacy/mask.test.ts new file mode 100644 index 0000000000..aa1b6dc7ce --- /dev/null +++ b/packages/core/test/privacy/mask.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { Mask } from "@redrob-code/core/privacy/mask" + +describe("privacy mask", () => { + test("regex tier finds email, phone, card and resident number; placeholders are stable", () => { + const vault = new Mask.Vault() + const text = "Mail hong@example.com or call 010-1234-5678. Card 4242 4242 4242 4242, RRN 900101-1234567. Order 12345678901234." + const masked = vault.maskText(text) + expect(masked).toBe("Mail [EMAIL_1] or call [PHONE_1]. Card [CARD_1], RRN [RRN_1]. Order 12345678901234.") + expect(vault.maskText("hong@example.com again")).toBe("[EMAIL_1] again") + expect(vault.unmask(masked)).toBe(text) + }) + + test("a request leaves with placeholders in user text and tool results, system prompt untouched", () => { + const vault = new Mask.Vault() + const request = { + model: {} as never, + system: [{ type: "text" as const, text: "You help hong@example.com" }], + messages: [ + { role: "user" as const, content: [{ type: "text" as const, text: "write to hong@example.com" }] }, + { + role: "tool" as const, + content: [ + { + type: "tool-result" as const, + id: "t1", + name: "read", + result: { type: "text" as const, value: "contact: hong@example.com" }, + }, + ], + }, + ], + tools: [], + } + const masked = Mask.maskRequest(vault, request as never) + expect(JSON.stringify(masked.messages)).not.toContain("hong@example.com") + expect(JSON.stringify(masked.messages)).toContain("[EMAIL_1]") + expect(masked.system[0]!.text).toContain("hong@example.com") + }) + + test("streamed answer is restored even when a placeholder splits across deltas; tool inputs are restored whole", () => { + const vault = new Mask.Vault() + vault.add({ value: "Hong Gildong", kind: "PERSON" }) + const unmask = Mask.unmaskEvents(vault) + const out: string[] = [] + for (const chunk of ["Dear [PER", "SON_", "1], hello [PERSON_1]", " and [PERSON_2]"]) { + for (const event of unmask({ type: "text-delta", id: "b", text: chunk } as never)) + if (event.type === "text-delta") out.push(event.text) + } + for (const event of unmask({ type: "text-end", id: "b" } as never)) if (event.type === "text-delta") out.push(event.text) + expect(out.join("")).toBe("Dear Hong Gildong, hello Hong Gildong and [PERSON_2]") + + const [call] = unmask({ type: "tool-call", id: "c", name: "write", input: { path: "[PERSON_1].md", lines: ["[PERSON_1]"] } } as never) + expect((call as { input: unknown }).input).toEqual({ path: "Hong Gildong.md", lines: ["Hong Gildong"] }) + }) + + test("model answer is parsed strictly; garbage is ignored", () => { + expect(Mask.parseEntities('Sure! {"PERSON":["Hong Gildong"],"ORG":["Redrob"],"LOC":[]} done')).toEqual([ + { value: "Hong Gildong", kind: "PERSON" }, + { value: "Redrob", kind: "ORG" }, + ]) + expect(Mask.parseEntities("no json here")).toEqual([]) + expect(Mask.parseEntities('{"PERSON": 42}')).toEqual([]) + }) + + test("settings default to off and the last document wins", () => { + expect(Mask.settings([]).mode).toBe("off") + expect( + Mask.settings([ + { info: { privacy: { mask_pii: "model", model: "ollama/qwen" } } }, + { info: { privacy: { mask_pii: "regex" } } }, + ]), + ).toEqual({ mode: "regex", model: "ollama/qwen" }) + }) +}) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 423a8a0992..4b092572de 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -34,6 +34,7 @@ import { SessionExecution } from "@redrob-code/core/session/execution" import { SessionRunCoordinator } from "@redrob-code/core/session/run-coordinator" import { SessionRunner } from "@redrob-code/core/session/runner" import * as SessionRunnerLLM from "@redrob-code/core/session/runner/llm" +import { Privacy } from "@redrob-code/core/privacy/service" import { SessionRunnerModel } from "@redrob-code/core/session/runner/model" import { ToolRegistry } from "@redrob-code/core/tool/registry" import { ApplicationTools } from "@redrob-code/core/tool/application-tools" @@ -251,42 +252,43 @@ const execution = Layer.effect( }) }), ).pipe(Layer.provide(runnerLayer)) -const it = testEffect( - AppNodeBuilder.build( - LayerNode.group([ - Database.node, - EventV2.node, - QuestionV2.node, - SessionProjector.node, - SessionStore.node, - ApplicationTools.node, - AgentV2.node, - ToolRegistry.node, - ToolRegistry.toolsNode, - echoNode, - SessionRunnerModel.node, - SystemContextRegistry.node, - SkillGuidance.node, - ReferenceGuidance.node, - Config.node, - Snapshot.node, - SessionRunnerLLM.node, - SessionExecution.node, - SessionV2.node, - ]), - [ - [LayerNodePlatform.llmClient, client], - [PermissionV2.node, permission], - [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillGuidance.node, skillGuidance], - [ReferenceGuidance.node, referenceGuidance], - [Snapshot.node, Snapshot.noopLayer], - [SessionExecution.node, execution], - [Config.node, config], - ], - ), +const sessionGroup = LayerNode.group([ + Database.node, + EventV2.node, + QuestionV2.node, + SessionProjector.node, + SessionStore.node, + ApplicationTools.node, + AgentV2.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + echoNode, + SessionRunnerModel.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + SessionExecution.node, + SessionV2.node, +]) +const sessionReplacements: LayerNode.Replacements = [ + [LayerNodePlatform.llmClient, client], + [PermissionV2.node, permission], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Snapshot.node, Snapshot.noopLayer], + [SessionExecution.node, execution], + [Config.node, config], +] +const it = testEffect(AppNodeBuilder.build(sessionGroup, sessionReplacements)) +/** The same runner with PII masking switched to `regex`; the default (`off`) is what `it` runs under. */ +const itMasked = testEffect( + AppNodeBuilder.build(sessionGroup, [...sessionReplacements, [Privacy.node, Privacy.layerWith({ mode: "regex" })]]), ) const sessionID = SessionV2.ID.make("ses_runner_test") const otherSessionID = SessionV2.ID.make("ses_runner_other") @@ -2534,6 +2536,42 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("sends the person's text as written while masking is off (the default)", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Mail hong@example.com today" }), resume: false }) + requests.length = 0 + yield* session.resume(sessionID) + expect(JSON.stringify(requests[0]?.messages)).toContain("hong@example.com") + }), + ) + + itMasked.effect("with masking on, the provider sees a placeholder and the person reads the real value", () => + Effect.gen(function* () { + yield* setup + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-masked" }), + LLMEvent.textDelta({ id: "text-masked", text: "Sent to [EMA" }), + LLMEvent.textDelta({ id: "text-masked", text: "IL_1]." }), + LLMEvent.textEnd({ id: "text-masked" }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ] + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Mail hong@example.com today" }), resume: false }) + requests.length = 0 + yield* session.resume(sessionID) + const sent = JSON.stringify(requests[0]?.messages) + expect(sent).not.toContain("hong@example.com") + expect(sent).toContain("[EMAIL_1]") + const stored = JSON.stringify(yield* session.messages({ sessionID })) + expect(stored).toContain("Sent to hong@example.com.") + expect(stored).not.toContain("[EMAIL_1]") + }), + ) + it.effect("adds the parent session header to child model requests", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/skill/arming.test.ts b/packages/core/test/skill/arming.test.ts new file mode 100644 index 0000000000..00dc901fd1 --- /dev/null +++ b/packages/core/test/skill/arming.test.ts @@ -0,0 +1,170 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import * as TestConsole from "effect/testing/TestConsole" +import { AgentV2 } from "@redrob-code/core/agent" +import { AppNodeBuilder } from "@redrob-code/core/effect/app-node-builder" +import { LayerNode } from "@redrob-code/core/effect/layer-node" +import { AbsolutePath } from "@redrob-code/core/schema" +import { SkillV2 } from "@redrob-code/core/skill" +import { SkillArming } from "@redrob-code/core/skill/arming" +import { SkillDiscovery } from "@redrob-code/core/skill/discovery" +import { tmpdir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" + +/** `arm` is pure, so most of K-2 needs no layer, no filesystem and no clock. */ +const skill = (name: string, autoInject?: SkillV2.AutoInject): SkillArming.Armable => ({ name, autoInject }) + +const names = (input: SkillArming.Input) => SkillArming.armedNames(input) + +describe("SkillArming.arm", () => { + const docs = skill("google-docs", { url: ["docs.google.com/document/**"] }) + const deploy = skill("deploy", { keywords: ["deploy", "rollback"] }) + /** No `autoInject` block at all: explicitly loadable, never auto-armed. */ + const manual = skill("manual") + + test("arms on a keyword hit", () => { + expect(names({ skills: [deploy, manual], prompt: "can you deploy this branch" })).toEqual(["deploy"]) + }) + + test("reports which keyword armed the skill", () => { + expect(SkillArming.arm({ skills: [deploy], prompt: "time to ROLLBACK" })).toEqual([ + { name: "deploy", keywords: ["rollback"], urls: [] }, + ]) + }) + + test("keyword matching is case-insensitive in both directions", () => { + expect(names({ skills: [skill("x", { keywords: ["RollBack"] })], prompt: "rollback now" })).toEqual(["x"]) + }) + + test("a keyword does not match inside a longer word", () => { + expect(names({ skills: [skill("ai", { keywords: ["ai"] })], prompt: "she said nothing" })).toEqual([]) + }) + + test("arms on a URL-glob hit", () => { + expect( + names({ + skills: [docs, manual], + prompt: "summarise this", + url: "https://docs.google.com/document/d/abc123/edit", + }), + ).toEqual(["google-docs"]) + }) + + test("URL matching ignores the scheme and is case-insensitive", () => { + expect(names({ skills: [docs], prompt: "", url: "HTTPS://Docs.Google.com/Document/d/ABC" })).toEqual([ + "google-docs", + ]) + }) + + test("a glob NEAR-MISS does not arm", () => { + // `documents` is not `document`, and a single `*` does not cross a `/`. + expect(names({ skills: [docs], prompt: "", url: "https://docs.google.com/documents/d/abc" })).toEqual([]) + expect( + names({ + skills: [skill("one-segment", { url: ["docs.google.com/document/*"] })], + prompt: "", + url: "https://docs.google.com/document/d/abc", + }), + ).toEqual([]) + // A `.` in the glob is a literal dot, not a regex wildcard. + expect(names({ skills: [docs], prompt: "", url: "https://docsXgoogle.com/document/d/abc" })).toEqual([]) + }) + + test("a skill with no autoInject block NEVER arms", () => { + expect( + names({ skills: [manual], prompt: "manual deploy rollback everything", url: "docs.google.com/document/d/abc" }), + ).toEqual([]) + }) + + test("an empty autoInject block matches nothing", () => { + expect(names({ skills: [skill("empty", {})], prompt: "deploy", url: "docs.google.com/document/d/a" })).toEqual([]) + }) + + test("no current URL means no glob can arm", () => { + expect(names({ skills: [docs], prompt: "summarise this" })).toEqual([]) + }) + + test("keyword and URL hits are both reported on one skill", () => { + expect( + SkillArming.arm({ + skills: [skill("both", { keywords: ["summarise"], url: ["docs.google.com/**"] })], + prompt: "Summarise it", + url: "https://docs.google.com/document/d/a", + }), + ).toEqual([{ name: "both", keywords: ["summarise"], urls: ["docs.google.com/**"] }]) + }) + + test("skills are returned in the order given", () => { + expect(names({ skills: [deploy, manual, skill("also", { keywords: ["deploy"] })], prompt: "deploy" })).toEqual([ + "deploy", + "also", + ]) + }) +}) + +const urls = new Map() +const discovery = Layer.succeed( + SkillDiscovery.Service, + SkillDiscovery.Service.of({ pull: (url) => Effect.succeed(urls.get(url) ?? []) }), +) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [[SkillDiscovery.node, discovery]]), +) + +describe("SkillV2.load frontmatter", () => { + it.live("ignores a malformed autoInject instead of failing the load, and drops only the bad document", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + // `autoInject` is a string where the schema wants an object: ignored, not fatal. + await fs.writeFile( + path.join(tmp.path, "broken-block.md"), + "---\nname: broken-block\nautoInject: nonsense\n---\n# broken-block", + ) + // A good neighbour, to prove one bad document does not cost the set. + await fs.writeFile( + path.join(tmp.path, "good.md"), + "---\nname: good\nicon: https://example.test/i.png\nautoInject:\n keywords: [deploy]\n url: [docs.google.com/document/**]\n---\n# good", + ) + // `slash` must be a boolean: this document IS dropped, with a logged reason. + await fs.writeFile(path.join(tmp.path, "bad-type.md"), "---\nname: bad-type\nslash: yes please\n---\n# bad") + }) + + const skills = yield* SkillV2.Service + yield* skills.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) })) + const loaded = yield* skills.list() + + // The undecodable document is gone; the two others survived. + expect(loaded.map((item) => item.name).toSorted()).toEqual(["broken-block", "good"]) + + const broken = loaded.find((item) => item.name === "broken-block")! + expect(broken.autoInject).toBeUndefined() + // Still loadable, just never auto-armed. + expect(names({ skills: [broken], prompt: "nonsense" })).toEqual([]) + + const good = loaded.find((item) => item.name === "good")! + expect(good.icon).toBe("https://example.test/i.png") + expect(good.autoInject).toEqual({ keywords: ["deploy"], url: ["docs.google.com/document/**"] }) + expect(names({ skills: [good], prompt: "please deploy" })).toEqual(["good"]) + expect(names({ skills: [good], prompt: "", url: "https://docs.google.com/document/d/x" })).toEqual(["good"]) + + // The drop must be FINDABLE: the count, the offending file and the reason all logged. + const logged = (yield* TestConsole.logLines).map((line) => String(line)).join("\n") + expect(logged).toContain("SkillV2.load dropped 1 of 3 skill documents") + expect(logged).toContain("bad-type.md") + expect(logged).toContain("frontmatter rejected") + expect(logged).toContain("ignored a malformed autoInject block") + expect(logged).toContain("broken-block.md") + // The documents that loaded are not reported as dropped. + expect(logged).not.toContain("good.md:") + }), + ), + ), + ) +}) diff --git a/packages/core/test/skill/skill-writer.test.ts b/packages/core/test/skill/skill-writer.test.ts new file mode 100644 index 0000000000..fdd0272ef9 --- /dev/null +++ b/packages/core/test/skill/skill-writer.test.ts @@ -0,0 +1,173 @@ +/** + * K-5: the skill that writes skills, verified by taking the frontmatter template out of the + * skill document itself and putting it through the REAL loader. + * + * The template is not retyped here and it is not asserted by eye. This test reads + * `SkillWriterContent` — the exact string the skill hands to an agent — extracts the fenced + * block the document marks as the template, writes it where the loader globs, and asserts the + * decoded `SkillV2.Info`. So a key the loader does not read, a shape its decoder rejects, or a + * typo in the document's own template fails here. + */ +import fs from "fs/promises" +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" +import { AgentV2 } from "@redrob-code/core/agent" +import { AppNodeBuilder } from "@redrob-code/core/effect/app-node-builder" +import { LayerNode } from "@redrob-code/core/effect/layer-node" +import { AbsolutePath } from "@redrob-code/core/schema" +import { SkillV2 } from "@redrob-code/core/skill" +import { SkillDiscovery } from "@redrob-code/core/skill/discovery" +import { SkillPlugin } from "@redrob-code/core/plugin/skill" +import { tmpdir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" + +const discovery = Layer.succeed( + SkillDiscovery.Service, + SkillDiscovery.Service.of({ pull: () => Effect.succeed([]) }), +) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [[SkillDiscovery.node, discovery]]), +) + +/** + * The template the document tells an agent to copy. Anchored on the document's own + * `` marker rather than on "the first code fence", so adding an earlier + * example to the document cannot silently repoint this at the wrong block. + * + * `\r` is stripped and the fence match tolerates CRLF: git checks this file out with native + * line endings, so on Windows the document arrives CRLF and an `\n`-only pattern finds no fence + * at all. That failed the Windows core job, which is the only place it could show. + */ +const template = (() => { + const marker = "" + const content = SkillPlugin.SkillWriterContent.replaceAll("\r\n", "\n") + const start = content.indexOf(marker) + if (start < 0) throw new Error("the skill-writer document no longer carries its template marker") + const fence = /```markdown\n([\s\S]*?)```/.exec(content.slice(start)) + if (!fence) throw new Error("no ```markdown fence follows the template marker") + return fence[1]! +})() + +const withTmpdir = (use: (directory: string) => Effect.Effect) => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap((tmp) => use(tmp.path))) + +/** Writes one document where the loader globs it and returns what the loader made of it. */ +const load = (directory: string, name: string, content: string) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(path.join(directory, name), { recursive: true }) + await fs.writeFile(path.join(directory, name, "SKILL.md"), content) + }) + const skill = yield* SkillV2.Service + yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(directory) })) + return yield* skill.list() + }) + +describe("K-5 the skill that writes skills", () => { + it.live("the template the document prescribes round-trips through the real loader", () => + withTmpdir((directory) => + Effect.gen(function* () { + const skills = yield* load(directory, "release-notes", template) + expect(skills).toHaveLength(1) + const skill = skills[0]! + + // Every frontmatter key the template declares, decoded by the loader — not read off + // the YAML by this test. + expect(skill.name).toBe("release-notes") + expect(skill.description).toBe( + "Use when drafting release notes from a tag range, or when the user asks what changed between two versions.", + ) + expect(skill.slash).toBe(true) + expect(skill.icon).toBe("https://code.redrob.ai/icon/release-notes.svg") + expect(skill.autoInject).toBeDefined() + expect(skill.autoInject!.keywords).toEqual(["release notes", "changelog"]) + expect(skill.autoInject!.url).toEqual(["github.com/*/releases/**"]) + expect(skill.location).toBe(AbsolutePath.make(path.join(directory, "release-notes", "SKILL.md"))) + expect(skill.content).toContain("# Drafting release notes") + }), + ), + ) + + it.live("the loaded skill re-decodes through the Info schema, so nothing passed by luck", () => + withTmpdir((directory) => + Effect.gen(function* () { + const skills = yield* load(directory, "release-notes", template) + // The loader builds `Info` with `make`, which does not run the decoder over the whole + // value. Putting the loaded skill back through `decodeUnknownSync` does, so a field + // the loader happened to accept in a shape the schema rejects fails here. + const again = Schema.decodeUnknownSync(SkillV2.Info)(JSON.parse(JSON.stringify(skills[0]!))) + expect(again.name).toBe("release-notes") + expect(again.autoInject).toEqual({ keywords: ["release notes", "changelog"], url: ["github.com/*/releases/**"] }) + }), + ), + ) + + it.live("a document missing the loader's one required key is DROPPED, which is what the skill warns about", () => + withTmpdir((directory) => + Effect.gen(function* () { + // The template's own claim under test: a SKILL.md in a subdirectory with no `name` has + // no fallback and does not become a skill. + const skills = yield* load( + directory, + "nameless", + template.replace("name: release-notes\n", ""), + ) + expect(skills).toHaveLength(0) + }), + ), + ) + + it.live("a malformed autoInject costs only the arming, exactly as the document says", () => + withTmpdir((directory) => + Effect.gen(function* () { + const broken = template.replace("autoInject:\n keywords:", "autoInject:\n keywords: not-a-list\n ignored:") + const skills = yield* load(directory, "release-notes", broken) + expect(skills).toHaveLength(1) + expect(skills[0]!.name).toBe("release-notes") + expect(skills[0]!.autoInject).toBeUndefined() + }), + ), + ) +}) + +describe("K-5 the skill document itself", () => { + it.live("every builtin skill decodes through the Info schema the loader uses", () => + Effect.gen(function* () { + expect(SkillPlugin.BuiltinSkills.map((skill) => skill.name)).toStrictEqual([ + "customize-redrob", + "documents", + "page-control", + "skill-writer", + ]) + for (const skill of SkillPlugin.BuiltinSkills) { + // `Info.make` does not decode, so a field in a shape the schema rejects would reach a + // session unnoticed. This runs the real decoder over each shipped skill. + const decoded = Schema.decodeUnknownSync(SkillV2.Info)(JSON.parse(JSON.stringify(skill))) + expect(decoded.name).toBe(skill.name) + expect(decoded.description).toBeTruthy() + expect(decoded.content.length).toBeGreaterThan(200) + } + const documents = SkillPlugin.BuiltinSkills.find((skill) => skill.name === "documents")! + expect(documents.autoInject?.keywords).toContain("docx") + expect(documents.autoInject?.url).toContain("docs.google.com/document/**") + // K-5 deliberately does not auto-arm. + expect(SkillPlugin.BuiltinSkills.find((skill) => skill.name === "skill-writer")!.autoInject).toBeUndefined() + }), + ) + + it.live("is registered as a builtin skill with the keys the loader reads", () => + Effect.gen(function* () { + expect(SkillPlugin.SkillWriterContent).toContain("# Writing a skill") + // The document claims these are the only frontmatter keys read. They are the keys the + // loader's own decoder declares, so a key added to one and not the other is caught. + for (const key of ["name", "description", "slash", "icon", "autoInject"]) { + expect(SkillPlugin.SkillWriterContent).toContain(`\`${key}\``) + } + expect(SkillPlugin.SkillWriterContent).toContain("{*.md,**/SKILL.md}") + }), + ) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index a9e479c71b..01d9a2ee70 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -18,7 +18,7 @@ import { type ToolDefinition } from "./tool.js" export * from "./tool.js" export type ProviderContext = { - source: "env" | "config" | "custom" | "api" + source: "env" | "config" | "custom" | "api" | "launcher" info: Provider options: Record } diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index e7256237ca..10710bafa1 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -14,14 +14,18 @@ import { EventGroup, makeEventGroup } from "./groups/event" import type { Definition } from "@redrob-code/schema/event" import { AgentGroup } from "./groups/agent" import { HealthGroup } from "./groups/health" +import { ClientsGroup } from "./groups/clients" import { PtyGroup } from "./groups/pty" +import { makeBrowserRequestGroup } from "./groups/browser-request" import { makeQuestionGroup } from "./groups/question" import { ReferenceGroup } from "./groups/reference" import { Authorization } from "./middleware/authorization" import { LocationGroup } from "./groups/location" import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" +import { PrivacyGroup } from "./groups/privacy" import { ProjectCopyGroup } from "./groups/project-copy" +import { ChatCompletionGroup } from "./groups/chat-completion" // Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream. const makeApiFromGroup = < @@ -37,6 +41,7 @@ const makeApiFromGroup = < ) => HttpApi.make("server") .add(HealthGroup) + .add(ClientsGroup) .add(LocationGroup.middleware(locationMiddleware)) .add(AgentGroup.middleware(locationMiddleware)) .add(makeSessionGroup(sessionLocationMiddleware)) @@ -46,6 +51,7 @@ const makeApiFromGroup = < .add(VariantGroup.middleware(locationMiddleware)) .add(IntegrationGroup.middleware(locationMiddleware)) .add(CredentialGroup.middleware(locationMiddleware)) + .add(PrivacyGroup.middleware(locationMiddleware)) .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) .add(FileSystemGroup.middleware(locationMiddleware)) .add(CommandGroup.middleware(locationMiddleware)) @@ -53,8 +59,13 @@ const makeApiFromGroup = < .add(eventGroup) .add(PtyGroup.middleware(locationMiddleware)) .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) + .add(makeBrowserRequestGroup(locationMiddleware, sessionLocationMiddleware)) .add(ReferenceGroup.middleware(locationMiddleware)) .add(ProjectCopyGroup.middleware(locationMiddleware)) + // The OpenAI-compatible inference route. It takes locationMiddleware like the + // rest: the provider registry it resolves against is per-location, so a + // project-local provider must be visible to it. + .add(ChatCompletionGroup.middleware(locationMiddleware)) .annotateMerge( OpenApi.annotations({ title: "redrob HttpApi", diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 3b1eced63a..d7fccf1792 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -95,6 +95,23 @@ export class QuestionNotFoundError extends Schema.TaggedErrorClass()( + "BrowserRequestNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class ForbiddenError extends Schema.TaggedErrorClass()( "ForbiddenError", { message: Schema.String }, @@ -109,3 +126,12 @@ export class PtyNotFoundError extends Schema.TaggedErrorClass( }, { httpApiStatus: 404 }, ) {} + +export class ClientNotFoundError extends Schema.TaggedErrorClass()( + "ClientNotFoundError", + { + clientID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/protocol/src/groups/browser-request.ts b/packages/protocol/src/groups/browser-request.ts new file mode 100644 index 0000000000..37e0f3559f --- /dev/null +++ b/packages/protocol/src/groups/browser-request.ts @@ -0,0 +1,102 @@ +import { BrowserRequest } from "@redrob-code/schema/browser-request" +import { Location } from "@redrob-code/schema/location" +import { Session } from "@redrob-code/schema/session" +import { Context, Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { BrowserRequestNotFoundError, SessionNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi } from "./location" + +/** + * PA-10: the client half of the browser channel. + * + * Three routes and no listener. The client already holds the engine's event stream, so it + * learns about a request there and settles it here — `reply` with a value, `refuse` with a + * sentence. The engine never connects to the browser, which is the property that keeps a + * local page or another local process from driving it. + */ +export const makeBrowserRequestGroup = < + LocationId extends HttpApiMiddleware.AnyId, + LocationService, + SessionLocationId extends HttpApiMiddleware.AnyId, + SessionLocationService, +>( + locationMiddleware: Context.Key, + sessionLocationMiddleware: Context.Key, +) => + HttpApiGroup.make("server.browser") + .add( + HttpApiEndpoint.get("browser.request.list", "/api/browser/request", { + query: LocationQuery, + success: Location.response(Schema.Array(BrowserRequest.Request)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v1.browser.request.list", + summary: "List pending browser requests", + description: + "Retrieve browser actions awaiting a client for a location. A client that reconnects uses this to recover requests published while it was away.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ title: "browser requests", description: "Browser actions awaiting a client." }), + ) + // Group middleware applies only to endpoints already added; the session endpoints below + // carry session placement instead. + .middleware(locationMiddleware) + .add( + HttpApiEndpoint.get("session.browser.list", "/api/session/:sessionID/browser", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(BrowserRequest.Request) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v1.session.browser.list", + summary: "List session browser requests", + description: "Retrieve browser actions awaiting a client, owned by a session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.browser.reply", "/api/session/:sessionID/browser/:requestID/reply", { + params: { sessionID: Session.ID, requestID: BrowserRequest.ID }, + payload: BrowserRequest.Reply, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, BrowserRequestNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v1.session.browser.reply", + summary: "Answer a pending browser request", + description: + "Settle a browser action with the value it produced. The value is typed per action, so answering with the wrong shape is reported to the engine's caller rather than read as a fact about the page.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.browser.refuse", "/api/session/:sessionID/browser/:requestID/refuse", { + params: { sessionID: Session.ID, requestID: BrowserRequest.ID }, + payload: BrowserRequest.Refusal, + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, BrowserRequestNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v1.session.browser.refuse", + summary: "Refuse a pending browser request", + description: + "Report that the action could not be performed, with the reason. Refusing is faster and more honest than letting the request expire on the engine's deadline.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "session browser requests", + description: "Session-owned browser action routes.", + }), + ) diff --git a/packages/protocol/src/groups/chat-completion.ts b/packages/protocol/src/groups/chat-completion.ts new file mode 100644 index 0000000000..373baea9d8 --- /dev/null +++ b/packages/protocol/src/groups/chat-completion.ts @@ -0,0 +1,243 @@ +/** + * `POST /v1/chat/completions` — the OpenAI-compatible inference route. + * + * This is the one surface every Redrob product is meant to reach the engine + * through: it carries the engine's own Console credential, so a product never + * holds a key of its own, and it resolves the same provider registry the agent + * does, so a locally-served model is reachable through the same call. + * + * It is NOT the agent. A chat completion is stateless and a session is not, so + * this route makes one model call and returns; mapping it onto the session API + * is what produced the broken half-implementations this replaces (see + * docs/LOCAL-ENGINE-API.md). + * + * WHO EXECUTES TOOLS IS DECIDED BY THE REQUEST, and that is the whole contract: + * + * - `tools` present -> the CALLER owns them. Nothing is executed here; the + * turn ends with `finish_reason: "tool_calls"` and the caller sends the + * results back as `role: "tool"` messages. Standard OpenAI semantics. + * - `tools` absent -> a plain completion. + * + * Hosts whose tools mutate something they hold — an open document, a live + * browser tab — cannot hand execution to the engine, so caller ownership is a + * requirement for them rather than a preference. + * + * The path is versioned, not the engine: fields may be ADDED here, but removing + * one or changing its meaning requires /v2. Products negotiate with + * /api/capabilities instead of pinning an engine version. + */ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +/** OpenAI puts the function under a `function` key and tags the wrapper. */ +const FunctionTool = Schema.Struct({ + type: Schema.Literal("function"), + function: Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + /** Raw JSON Schema, passed to the provider untouched. */ + parameters: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }), +}) + +const ToolCall = Schema.Struct({ + id: Schema.String, + type: Schema.Literal("function"), + function: Schema.Struct({ + name: Schema.String, + /** A JSON *string*, which is what OpenAI clients parse. */ + arguments: Schema.String, + }), +}) + +/** + * Message content is a string in the common case and a part array when a client + * sends images. `Schema.Unknown` for the array case keeps this route from + * becoming a second, divergent definition of multimodal content — the parts are + * handed to the provider layer, which owns that shape. + */ +const MessageContent = Schema.Union([ + Schema.String, + Schema.Null, + Schema.Array(Schema.Record(Schema.String, Schema.Unknown)), +]) + +const Message = Schema.Struct({ + role: Schema.Literals(["system", "developer", "user", "assistant", "tool"]), + content: Schema.optional(MessageContent), + /** Present on an assistant message that is replaying a previous tool turn. */ + tool_calls: Schema.optional(Schema.Array(ToolCall)), + /** Required on a `tool` message: which call this is the result of. */ + tool_call_id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), +}) + +const ToolChoice = Schema.Union([ + Schema.Literals(["auto", "none", "required"]), + Schema.Struct({ + type: Schema.Literal("function"), + function: Schema.Struct({ name: Schema.String }), + }), +]) + +export const ChatCompletionRequest = Schema.Struct({ + /** `redrob/auto` routes through the Console; a local provider id stays local. */ + model: Schema.String, + messages: Schema.Array(Message), + tools: Schema.optional(Schema.Array(FunctionTool)), + tool_choice: Schema.optional(ToolChoice), + /** Default false. True returns text/event-stream. */ + stream: Schema.optional(Schema.Boolean), + max_tokens: Schema.optional(Schema.Int), + /** OpenAI's newer name for the same cap; `max_tokens` wins if both appear. */ + max_completion_tokens: Schema.optional(Schema.Int), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + stop: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + seed: Schema.optional(Schema.Int), + frequency_penalty: Schema.optional(Schema.Number), + presence_penalty: Schema.optional(Schema.Number), + /** Passed through where the provider accepts it. */ + reasoning_effort: Schema.optional(Schema.String), + /** Accepted and ignored: a caller may send it, and refusing would be rude. */ + user: Schema.optional(Schema.String), +}).annotate({ identifier: "ChatCompletionRequest" }) +export type ChatCompletionRequest = typeof ChatCompletionRequest.Type + +const Usage = Schema.Struct({ + prompt_tokens: Schema.Int, + completion_tokens: Schema.Int, + total_tokens: Schema.Int, +}) + +const Choice = Schema.Struct({ + index: Schema.Int, + message: Schema.Struct({ + role: Schema.Literal("assistant"), + content: Schema.NullOr(Schema.String), + tool_calls: Schema.optional(Schema.Array(ToolCall)), + /** Present when the model emitted reasoning; not an OpenAI field. */ + reasoning_content: Schema.optional(Schema.String), + }), + finish_reason: Schema.NullOr(Schema.String), +}) + +export const ChatCompletionResponse = Schema.Struct({ + id: Schema.String, + object: Schema.Literal("chat.completion"), + created: Schema.Int, + model: Schema.String, + choices: Schema.Array(Choice), + usage: Schema.optional(Usage), +}).annotate({ identifier: "ChatCompletionResponse" }) +export type ChatCompletionResponse = typeof ChatCompletionResponse.Type + +/** + * One frame of the `stream: true` response. + * + * A DIFFERENT shape from `ChatCompletionResponse`, which is why documenting the streaming + * path needed this rather than reusing the JSON one: the object discriminator is + * `chat.completion.chunk`, and each choice carries a partial `delta` instead of a complete + * `message`. A generated client that assumed the non-streaming shape would look for + * `choices[].message.content` and find nothing on every frame. + * + * Declared for documentation and codegen only — the handler is `handleRaw` and writes SSE + * frames itself, because one request answers with JSON and another with + * `text/event-stream`. This type is what makes the OpenAPI honest about the second. + * + * Every field on `delta` is optional because OpenAI's stream uses the shape sparsely: the + * first frame typically carries only `role`, middle frames only `content`, a tool call + * arrives spread across frames, and the terminator carries an empty delta with + * `finish_reason` set. A schema that required `content` would reject the terminator. + */ +export const ChatCompletionChunk = Schema.Struct({ + id: Schema.String, + object: Schema.Literal("chat.completion.chunk"), + created: Schema.Int, + model: Schema.String, + choices: Schema.Array( + Schema.Struct({ + index: Schema.Int, + delta: Schema.Struct({ + role: Schema.optional(Schema.Literal("assistant")), + content: Schema.optional(Schema.String), + tool_calls: Schema.optional(Schema.Array(ToolCall)), + reasoning_content: Schema.optional(Schema.String), + }), + finish_reason: Schema.NullOr(Schema.String), + }), + ), + /** + * Sent on the final frame by providers that report it. Optional because many do not, + * and a client must not wait for a frame that never comes. + */ + usage: Schema.optional(Usage), +}).annotate({ identifier: "ChatCompletionChunk" }) +export type ChatCompletionChunk = typeof ChatCompletionChunk.Type + +/** + * The OpenAI error envelope. + * + * NOT declared on the endpoint, and that is deliberate rather than an omission. + * Two independent reasons: + * + * 1. The handler cannot use declared error schemas. It is `handleRaw`, because + * one request answers with JSON and another with `text/event-stream`, so it + * writes every response itself — including failures, through `errorResponse` + * in `chat-completion.ts`. Declared error classes were never on the path. + * 2. `httpapi-codegen` requires each declared endpoint error to carry a `_tag` + * or `name` STRING LITERAL to discriminate on (`declaredErrorFields`), and + * four statuses sharing one envelope have nothing to discriminate by. + * Adding `_tag` to satisfy it would make the generated spec and SDK claim a + * field the wire does not carry, since OpenAI's envelope has no such key — + * a spec that lies is worse than a spec that is silent. + * + * So the statuses are documented in the endpoint description and specified in + * `docs/LOCAL-ENGINE-API.md`, and this type exists to keep the shape in one place + * for the handler to build against. + * + * `code` is the part clients must branch on. `engine_not_authenticated` means the + * ENGINE has no credential, and it is the only condition under which a client + * should offer a sign-in action — the message text is localized downstream and is + * not a contract. Branching on text instead is how a network timeout reached a + * user as "please log in". + */ +export const ChatCompletionError = Schema.Struct({ + error: Schema.Struct({ + message: Schema.String, + type: Schema.String, + code: Schema.NullOr(Schema.String), + param: Schema.optional(Schema.NullOr(Schema.String)), + }), +}).annotate({ identifier: "ChatCompletionError" }) +export type ChatCompletionError = typeof ChatCompletionError.Type + +export const ChatCompletionGroup = HttpApiGroup.make("server.chat") + .add( + HttpApiEndpoint.post("chat.completions", "/v1/chat/completions", { + payload: ChatCompletionRequest, + success: ChatCompletionResponse, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v1.chat.completions", + summary: "Create a chat completion", + description: + "OpenAI-compatible inference against the engine's own credential and provider registry. " + + "Declaring `tools` makes the caller responsible for executing them: the turn ends with " + + "finish_reason tool_calls and the results come back as role:tool messages. " + + "`stream: true` returns text/event-stream instead of this JSON body. " + + "Failures use OpenAI's error envelope -- {error:{message,type,code,param}} -- with " + + "400 invalid_request_error, 401 authentication_error (code engine_not_authenticated, the " + + "only status a client should offer a sign-in action for), 429 rate_limit_error or " + + "insufficient_quota, and 502 api_error. They are written by the handler rather than " + + "declared as endpoint errors, because this is a raw handler and the envelope carries no " + + "discriminator field for codegen to branch on.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "chat completions", + description: "OpenAI-compatible inference route. The shared engine surface for Redrob products.", + }), + ) diff --git a/packages/protocol/src/groups/clients.ts b/packages/protocol/src/groups/clients.ts new file mode 100644 index 0000000000..75481d837f --- /dev/null +++ b/packages/protocol/src/groups/clients.ts @@ -0,0 +1,75 @@ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ClientNotFoundError } from "../errors" + +/** + * Who is attached to this engine. Apps attach when they connect to the shared daemon and send a + * heartbeat while they run; the daemon exits once nobody has been attached for a while + * (`redrob serve --register --idle-exit`). The launcher reads the list to show "3 apps attached". + * No location: this is about the process, not a project. + */ +export const Client = Schema.Struct({ + id: Schema.String, + app: Schema.String, + pid: Schema.optional(Schema.Number), + attachedAt: Schema.Number, + lastSeen: Schema.Number, +}).annotate({ identifier: "EngineClient" }) + +export const ClientList = Schema.Struct({ + clients: Schema.Array(Client), + /** Heartbeat deadline in ms: an attachment with no heartbeat for this long is dropped. */ + ttlMs: Schema.Number, + /** How long nobody has been attached, in ms; absent while someone is. */ + idleForMs: Schema.optional(Schema.Number), + /** This daemon's idle policy: exit after this many ms with nobody attached; absent when it stays up. */ + idleExitAfterMs: Schema.optional(Schema.Number), +}).annotate({ identifier: "EngineClientList" }) + +export const ClientsGroup = HttpApiGroup.make("server.clients") + .add( + HttpApiEndpoint.get("clients.list", "/api/clients", { success: ClientList }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.clients.list", + summary: "List attached apps", + description: "The apps attached to this engine and how long it has been idle.", + }), + ), + ) + .add( + HttpApiEndpoint.post("clients.attach", "/api/clients", { + payload: Schema.Struct({ app: Schema.String, pid: Schema.optional(Schema.Number) }), + success: Client, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.clients.attach", + summary: "Attach an app", + description: "Tell the engine an app depends on it. Send a heartbeat at least every ttlMs/2 while it runs.", + }), + ), + ) + .add( + HttpApiEndpoint.post("clients.heartbeat", "/api/clients/:clientID/heartbeat", { + params: { clientID: Schema.String }, + success: Client, + error: ClientNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.clients.heartbeat", + summary: "Keep an attachment alive", + description: "404 when the attachment expired or was never made; attach again.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("clients.detach", "/api/clients/:clientID", { + params: { clientID: Schema.String }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.clients.detach", + summary: "Detach an app", + description: "The app no longer depends on the engine.", + }), + ), + ) diff --git a/packages/protocol/src/groups/privacy.ts b/packages/protocol/src/groups/privacy.ts new file mode 100644 index 0000000000..39f12f0d5b --- /dev/null +++ b/packages/protocol/src/groups/privacy.ts @@ -0,0 +1,47 @@ +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +/** + * The privacy switch. One value for the whole engine, so an app shows a toggle and nothing more: + * `off` (default), `regex` (emails, phones, cards, resident numbers), `model` (regex plus a local + * model naming people, organisations and places). + */ +export const PrivacySettings = Schema.Struct({ + mode: Schema.Literals(["off", "regex", "model"]), + model: Schema.optional(Schema.String).annotate({ + description: "For mode = model: the LOCAL model that finds names, as provider/model", + }), +}).annotate({ identifier: "PrivacySettings" }) + +export const PrivacyGroup = HttpApiGroup.make("server.privacy") + .add( + HttpApiEndpoint.get("privacy.get", "/api/privacy", { + query: LocationQuery, + success: PrivacySettings, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.privacy.get", + summary: "Read the PII masking setting", + description: "What personal data may leave this machine inside a model request.", + }), + ), + ) + .add( + HttpApiEndpoint.put("privacy.set", "/api/privacy", { + query: LocationQuery, + payload: PrivacySettings, + success: PrivacySettings, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.privacy.set", + summary: "Change the PII masking setting", + description: + "Takes effect on the next message and is written to the user's global redrob.json, so it holds across restarts and apps.", + }), + ), + ) diff --git a/packages/redrob/package.json b/packages/redrob/package.json index 077caf56eb..1e4b2ea726 100644 --- a/packages/redrob/package.json +++ b/packages/redrob/package.json @@ -117,8 +117,10 @@ "cross-spawn": "catalog:", "decimal.js": "10.5.0", "diff": "catalog:", + "docx": "9.8.1", "drizzle-orm": "catalog:", "effect": "catalog:", + "exceljs": "4.4.0", "fuzzysort": "3.1.0", "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", @@ -136,6 +138,8 @@ "opencode-poe-auth": "0.0.1", "opentui-spinner": "catalog:", "partial-json": "0.1.7", + "pdf-lib": "1.17.1", + "pptxgenjs": "4.0.1", "remeda": "catalog:", "semver": "^7.6.3", "solid-js": "catalog:", diff --git a/packages/redrob/script/launcher-assets.ts b/packages/redrob/script/launcher-assets.ts new file mode 100644 index 0000000000..1bba4eec5d --- /dev/null +++ b/packages/redrob/script/launcher-assets.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env bun +/** + * Release assets in the shape Redrob Launcher installs from. + * + * The launcher reads a release's asset list and installs only names of the form + * + * ---[-portable]. + * + * where the stem is this repository's name, the version carries no `v`, and the os-arch token + * is one of a fixed set (`linux-x86_64`, not `linux-x64`; `macos-aarch64`, not `darwin-arm64`). + * The build names its outputs after Bun's targets (`redrob-linux-x64`, `redrob-darwin-arm64`), + * which the launcher cannot parse, so nothing here was installable from it. + * + * The build's own names STAY. `install.sh`, `install.ps1`, `redrob upgrade` + * (`src/installation/index.ts`), the Homebrew and AUR definitions in `publish.ts` and the + * Dockerfile all download `redrob--.` by that exact name, so renaming would break + * every existing install's self-update. The launcher assets are ADDITIONAL copies, one per + * platform the launcher has a token for -- the baseline and musl variants have no launcher + * token and are not duplicated. + * + * Linux tarballs are repacked rather than copied: the launcher unpacks a tarball into + * `/bin/` and looks for the binary under the app's id, `redrob-code`, so the + * launcher tarball carries `bin/redrob-code` with `bin/redrob` beside it as a symlink -- the + * same two names `install.sh` creates. macOS and Windows archives are byte-for-byte copies of + * the signed zips: the binary inside is what was notarized or Authenticode-signed, and + * re-archiving it would gain nothing. + * + * Usage (from the release workflow's publish job): + * + * launcher-assets.ts --version 0.5.1 --unsigned unsigned --signed assets --out assets + * + * `--unsigned` is the build artifact (`redrob--/bin/redrob` directories); `--signed` + * holds the signed macOS and Windows zips; `--out` receives the launcher assets. Exits non-zero + * if any expected input is missing, so a release cannot quietly ship without a platform. + * + * Beside the installers it writes `redrob-launcher.json`, the release manifest the launcher reads + * to learn that this release uses the launcher's shared account (`packages/core/src/shared-account.ts`), + * and `verifyReleaseDirectory` refuses a release directory that lacks it or carries a wrong one. + */ +import fs from "node:fs" +import path from "node:path" +import { $ } from "bun" + +/** The stem Redrob Launcher expects for this app: the repository name. */ +export const STEM = "redrob-code" + +/** + * The release manifest the launcher reads beside the installers (the launcher's + * `docs/release-manifest.md`). `schema` is 1; `sharedAccount` is true because every release from + * here on reads the launcher's shared account file as its last console-key fallback. + */ +export const MANIFEST_NAME = "redrob-launcher.json" +export const MANIFEST = { schema: 1, sharedAccount: true } as const + +/** The manifest's bytes, exactly as the launcher contract spells them, newline-terminated. */ +export function manifestText(): string { + return `{ "schema": ${MANIFEST.schema}, "sharedAccount": ${MANIFEST.sharedAccount} }\n` +} + +/** Writes the manifest into `out` and returns its path. */ +export function writeManifest(out: string): string { + fs.mkdirSync(out, { recursive: true }) + const dest = path.join(out, MANIFEST_NAME) + fs.writeFileSync(dest, manifestText()) + return dest +} + +/** + * Checks a release directory is one the launcher can use: every launcher installer for `version` + * is present and `redrob-launcher.json` exists, is JSON, is schema 1 and says `sharedAccount: true`. + * Returns the problems found, empty when the directory passes. + */ +export function verifyReleaseDirectory(dir: string, version: string): string[] { + const problems: string[] = [] + for (const name of expectedLauncherAssets(version)) { + if (!fs.existsSync(path.join(dir, name))) problems.push(`missing launcher asset ${name}`) + } + const manifest = path.join(dir, MANIFEST_NAME) + if (!fs.existsSync(manifest)) { + problems.push(`missing ${MANIFEST_NAME}`) + return problems + } + let parsed: unknown + try { + parsed = JSON.parse(fs.readFileSync(manifest, "utf8")) + } catch (error) { + problems.push(`${MANIFEST_NAME} is not JSON: ${error instanceof Error ? error.message : String(error)}`) + return problems + } + const record = (typeof parsed === "object" && parsed !== null ? parsed : {}) as Record + if (record.schema !== MANIFEST.schema) + problems.push(`${MANIFEST_NAME} schema is ${JSON.stringify(record.schema)}, expected ${MANIFEST.schema}`) + if (record.sharedAccount !== MANIFEST.sharedAccount) + problems.push( + `${MANIFEST_NAME} sharedAccount is ${JSON.stringify(record.sharedAccount)}, expected ${MANIFEST.sharedAccount}`, + ) + return problems +} + +/** + * Build output directory -> launcher os-arch token. Only the variants the launcher has a token + * for; see `Platform::tokens` in the launcher's `crates/core/src/platform.rs`. + */ +export const TARGETS: Record = { + "redrob-linux-x64": { token: "linux-x86_64", kind: "tar.gz" }, + "redrob-linux-arm64": { token: "linux-aarch64", kind: "tar.gz" }, + "redrob-darwin-arm64": { token: "macos-aarch64", kind: "portable.zip" }, + "redrob-darwin-x64": { token: "macos-x86_64", kind: "portable.zip" }, + "redrob-windows-x64": { token: "windows-x64", kind: "portable.zip" }, + "redrob-windows-arm64": { token: "windows-arm64", kind: "portable.zip" }, +} + +/** The os-arch tokens the launcher parses, verbatim from `Platform::tokens`. */ +export const LAUNCHER_TOKENS = new Set([ + "linux-x86_64", + "linux-aarch64", + "linux-riscv64", + "macos-universal", + "macos-aarch64", + "macos-x86_64", + "windows-x64", + "windows-arm64", + "windows-x86", + "freebsd-x86_64", +]) + +/** The package suffixes the launcher installs, verbatim from `PackageKind::suffix`. */ +export const LAUNCHER_SUFFIXES = ["-portable.zip", ".tar.gz", ".msi", ".dmg", ".AppImage", ".deb", ".rpm", ".flatpak"] + +/** `--.` for one build output. */ +export function launcherAssetName(version: string, token: string, kind: "tar.gz" | "portable.zip") { + return `${STEM}-${version}-${token}${kind === "tar.gz" ? ".tar.gz" : "-portable.zip"}` +} + +/** Every launcher asset a release of `version` must carry. */ +export function expectedLauncherAssets(version: string) { + return Object.values(TARGETS).map((t) => launcherAssetName(version, t.token, t.kind)) +} + +/** + * Whether the launcher would parse `name`. Mirrors `parse_asset_name` in the launcher: strip a + * known suffix, strip the stem, then split the rest from the right into arch, os and version, + * and require a version that starts with a digit and a token the launcher knows. + */ +export function isLauncherAssetName(name: string, stem = STEM): boolean { + const suffix = LAUNCHER_SUFFIXES.find((s) => name.endsWith(s)) + if (!suffix) return false + const base = name.slice(0, -suffix.length) + if (!base.startsWith(stem + "-")) return false + const rest = base.slice(stem.length + 1) + const parts = rest.split("-") + if (parts.length < 3) return false + const arch = parts.pop()! + const os = parts.pop()! + const version = parts.join("-") + if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(version)) return false + return LAUNCHER_TOKENS.has(`${os}-${arch}`) +} + +export interface Inputs { + version: string + unsigned: string + signed: string + out: string +} + +/** + * Produces every launcher asset into `out`, then the manifest. Returns the installer names + * written, in TARGETS order; the manifest is not an installer and is not among them. + */ +export async function pack(inputs: Inputs): Promise { + const written: string[] = [] + fs.mkdirSync(inputs.out, { recursive: true }) + for (const [dir, target] of Object.entries(TARGETS)) { + const name = launcherAssetName(inputs.version, target.token, target.kind) + const dest = path.join(inputs.out, name) + if (target.kind === "tar.gz") { + const binary = path.join(inputs.unsigned, dir, "bin", "redrob") + if (!fs.existsSync(binary)) throw new Error(`missing build output ${binary}`) + // Top folder named like the asset, as the launcher's own fixtures lay it out. + const top = name.slice(0, -".tar.gz".length) + const staging = fs.mkdtempSync(path.join(inputs.out, ".launcher-")) + try { + const bin = path.join(staging, top, "bin") + fs.mkdirSync(bin, { recursive: true }) + fs.copyFileSync(binary, path.join(bin, STEM)) + fs.chmodSync(path.join(bin, STEM), 0o755) + fs.symlinkSync(STEM, path.join(bin, "redrob")) + await $`tar -czf ${path.resolve(dest)} ${top}`.cwd(staging) + } finally { + fs.rmSync(staging, { recursive: true, force: true }) + } + } else { + const zip = path.join(inputs.signed, `${dir}.zip`) + if (!fs.existsSync(zip)) throw new Error(`missing signed archive ${zip}`) + fs.copyFileSync(zip, dest) + } + written.push(name) + } + writeManifest(inputs.out) + return written +} + +function arg(name: string): string { + const i = process.argv.indexOf(`--${name}`) + if (i === -1 || !process.argv[i + 1]) throw new Error(`--${name} is required`) + return process.argv[i + 1] +} + +if (import.meta.main) { + const inputs: Inputs = { version: arg("version"), unsigned: arg("unsigned"), signed: arg("signed"), out: arg("out") } + if (!/^\d/.test(inputs.version)) throw new Error(`version must not carry a leading v: ${inputs.version}`) + const names = await pack(inputs) + for (const name of names) { + if (!isLauncherAssetName(name)) throw new Error(`produced a name the launcher cannot parse: ${name}`) + console.log(name) + } + console.log(MANIFEST_NAME) + const problems = verifyReleaseDirectory(inputs.out, inputs.version) + if (problems.length) + throw new Error(`release directory ${inputs.out} is not launcher-ready:\n ${problems.join("\n ")}`) +} diff --git a/packages/redrob/src/acp/service.ts b/packages/redrob/src/acp/service.ts index 83064c8bd7..76568bdffb 100644 --- a/packages/redrob/src/acp/service.ts +++ b/packages/redrob/src/acp/service.ts @@ -793,7 +793,9 @@ function defaultModelFromConfig( // a default. Configured model, redrob provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. const redrobProvider = providers[ProviderV2.ID.make("redrob")] - const redrobModel = redrobProvider ? Provider.sort(Object.values(redrobProvider.models))[0] : undefined + const redrobModel = redrobProvider + ? (redrobProvider.models[Provider.REDROB_DEFAULT_MODEL] ?? Provider.sort(Object.values(redrobProvider.models))[0]) + : undefined if (redrobProvider && redrobModel) return { providerID: redrobProvider.id, modelID: redrobModel.id } const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0] diff --git a/packages/redrob/src/cli/cmd/acp.ts b/packages/redrob/src/cli/cmd/acp.ts index 20853e1967..9a6b81ee74 100644 --- a/packages/redrob/src/cli/cmd/acp.ts +++ b/packages/redrob/src/cli/cmd/acp.ts @@ -10,24 +10,46 @@ export const AcpCommand = effectCmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { - return withNetworkOptions(yargs).option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) + return withNetworkOptions(yargs) + .option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) + .option("attach", { + describe: + "use the user's shared engine (start it if needed) instead of running one inside this process; one daemon serves every app", + type: "boolean", + default: false, + }) }, handler: Effect.fn("Cli.acp")(function* (args) { const { Server } = yield* Effect.promise(() => import("@/server/server")) const { ACP } = yield* Effect.promise(() => import("@/acp/agent")) ACPProfile.mark("cli.acp.handler") process.env.REDROB_CLIENT = "acp" - const opts = yield* resolveNetworkOptions(args) - const server = yield* Effect.promise(() => ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts))) - - const sdk = createRedrobClient({ - baseUrl: `http://${server.hostname}:${server.port}`, - headers: ServerAuth.headers(), - }) + // --attach: this process is only the ACP face; the engine is the shared daemon. Without it the + // engine runs in here, as it always has. The attachment is what keeps the daemon from counting + // this session as idle time; it is released when the host closes stdin. + let detach: (() => Promise) | undefined + const sdk = args.attach + ? yield* Effect.promise(async () => { + const { Daemon } = await import("@/daemon/connect") + const connection = await Daemon.connectOrStart() + const attachment = await Daemon.attach(connection, process.env.REDROB_APP?.trim() || "acp") + detach = attachment.detach + return createRedrobClient({ baseUrl: connection.url, headers: connection.headers }) + }) + : yield* Effect.gen(function* () { + const opts = yield* resolveNetworkOptions(args) + const server = yield* Effect.promise(() => + ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts)), + ) + return createRedrobClient({ + baseUrl: `http://${server.hostname}:${server.port}`, + headers: ServerAuth.headers(), + }) + }) const input = new WritableStream({ write(chunk) { @@ -69,5 +91,6 @@ export const AcpCommand = effectCmd({ process.stdin.on("error", reject) }), ) + if (detach) yield* Effect.promise(() => detach!()) }), }) diff --git a/packages/redrob/src/cli/cmd/models-local.ts b/packages/redrob/src/cli/cmd/models-local.ts new file mode 100644 index 0000000000..5627069767 --- /dev/null +++ b/packages/redrob/src/cli/cmd/models-local.ts @@ -0,0 +1,134 @@ +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { LayerNode } from "@redrob-code/core/effect/layer-node" +import { AppProcess } from "@redrob-code/core/process" +import { LocalGpuProbe } from "@redrob-code/core/config/plugin/local-gpu-probe" +import { LocalModelFit } from "@redrob-code/core/config/plugin/local-model-fit" +import { LocalModelPull } from "@redrob-code/core/config/plugin/local-model-pull" +import { isLocalEndpoint } from "@redrob-code/core/config/plugin/local-provider" +import { Config } from "@/config/config" +import { effectCmd } from "../effect-cmd" +import { UI } from "../ui" + +/** + * PA-8's surface: what this machine can run, and what it cannot, with the numbers. + * + * The judgement and the probes are libraries with their own tests; this is the one place a + * person can see them. Without it the fit check is code nobody reaches -- the failure this + * queue has now found three times (an arming matcher with no caller, a model preference read + * and discarded, builtin skills registered into a list with no consumer). + * + * A COMMAND rather than a settings page, because the engine has no settings UI of its own and + * the browser's would mean a mojom round trip for a read that is already local. The browser + * can call this later; the numbers are the part that had nowhere to appear. + * + * TOP-LEVEL rather than `models local`, deliberately. `models` takes a POSITIONAL provider + * filter (`models anthropic`), so adding a subcommand under it makes `models local` ambiguous + * -- yargs cannot tell the subcommand from a provider that happens to be called `local`. + * + * NO CLOUD FALLBACK is offered or hinted at anywhere here. A user asking what their own + * machine can run is not asking to be sold a hosted model. + */ + +const GiB = 1024 ** 3 +const gib = (bytes: number): string => `${(bytes / GiB).toFixed(1)} GiB` + +export const ModelsLocalCommand = effectCmd({ + command: "models-local", + describe: "what this machine can run, and what it cannot", + builder: (yargs) => yargs, + handler: Effect.fn("Cli.models.local")( + function* (_args) { + /* + * A failed GPU probe reports NOT MEASURED, never "no GPU". + * + * The probe shells out, and on a machine with no discrete card the tool simply does + * not exist -- the normal state of the world, not a condition to report. The memory + * judgement, which is the part that actually decides whether a model runs, does not + * depend on it. + */ + const gpu = yield* LocalGpuProbe.probe(process.platform).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ) + const hardware = LocalModelFit.measure(gpu) + + UI.println(UI.Style.TEXT_NORMAL_BOLD + "This machine" + UI.Style.TEXT_NORMAL) + UI.println(` platform ${hardware.platform} ${hardware.arch}`) + UI.println(` cores ${hardware.cores}`) + UI.println(` memory ${gib(hardware.availableBytes)} free of ${gib(hardware.totalBytes)}`) + // "not measured", never "none". A machine with a card we could not see must not be told + // it has no card -- that is a false statement about the user's own computer. + UI.println( + ` gpu ${ + gpu === undefined + ? "not measured on this platform" + : gpu.vramBytes === undefined + ? gpu.name + : `${gpu.name}, ${gib(gpu.vramBytes)}` + }`, + ) + UI.println("") + + // Only endpoints the local rule already allows. Re-checked here rather than trusted, + // the same way the model listing re-checks it: this command would otherwise be a way to + // make the CLI call an arbitrary host by editing a config file. + const config = yield* (yield* Config.Service).get() + const endpoints = Object.values(config.provider ?? {}) + .map((provider) => (provider as { options?: { baseURL?: string } }).options?.baseURL) + .filter((url): url is string => typeof url === "string" && isLocalEndpoint(url)) + + if (endpoints.length === 0) { + UI.println(UI.Style.TEXT_DIM + "No local model runtime is configured." + UI.Style.TEXT_NORMAL) + // Said plainly, because "no models" and "nowhere to look for models" are different + // facts and a user who sees the first will go looking for a model they already have. + UI.println( + UI.Style.TEXT_DIM + + "Point a provider at one on this machine to see what it is serving." + + UI.Style.TEXT_NORMAL, + ) + return + } + + let sawRuntime = false + for (const endpoint of endpoints) { + const { reachable, models } = yield* LocalModelPull.installed(endpoint) + if (!reachable) { + // Not an error. A runtime that is not running is the normal state of a laptop, and + // the honest line says which endpoint did not answer rather than implying the + // machine cannot run anything. + UI.println(`${endpoint} ${UI.Style.TEXT_DIM}not answering${UI.Style.TEXT_NORMAL}`) + continue + } + sawRuntime = true + UI.println(UI.Style.TEXT_NORMAL_BOLD + endpoint + UI.Style.TEXT_NORMAL) + if (models.length === 0) { + UI.println(` ${UI.Style.TEXT_DIM}answering, with nothing installed${UI.Style.TEXT_NORMAL}`) + continue + } + for (const model of models) { + const verdict = LocalModelFit.fit({ modelBytes: model.size, hardware }) + if (verdict.kind === "fits") { + UI.println(` ${model.name} ${gib(model.size)} runs here`) + continue + } + // The refusal carries its own numbers, so the line is the library's sentence rather + // than a re-worded summary that could disagree with it. + UI.println(` ${model.name} ${gib(model.size)} ${UI.Style.TEXT_DIM}${verdict.reason}${UI.Style.TEXT_NORMAL}`) + } + } + + if (!sawRuntime) { + UI.println("") + UI.println( + UI.Style.TEXT_DIM + + "Every configured local endpoint is down, so nothing could be listed." + + UI.Style.TEXT_NORMAL, + ) + } + }, (effect) => + // Both provided here rather than added to the CLI runtime: this is the only command that + // speaks HTTP to a local runtime or shells out for a GPU, and widening the shared runtime + // for one command would hand both to every other command that has no use for them. + Effect.provide(effect, Layer.mergeAll(FetchHttpClient.layer, LayerNode.compile(AppProcess.node))), + ), +}) diff --git a/packages/redrob/src/cli/cmd/serve.ts b/packages/redrob/src/cli/cmd/serve.ts index b84df65ddc..8b09624335 100644 --- a/packages/redrob/src/cli/cmd/serve.ts +++ b/packages/redrob/src/cli/cmd/serve.ts @@ -2,6 +2,7 @@ import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@redrob-code/core/flag/flag" +import { InstallationVersion } from "@redrob-code/core/installation/version" // The startup handshake Redrob Work depends on. // @@ -15,22 +16,85 @@ export const SERVER_LISTENING_PREFIX = "redrob server listening" export const serverListeningLine = (server: { hostname: string; port: number }) => `${SERVER_LISTENING_PREFIX} on http://${server.hostname}:${server.port}` +/** Default minutes with nobody attached before a registered daemon exits. */ +export const DEFAULT_IDLE_EXIT_MINUTES = 10 + export const ServeCommand = effectCmd({ command: "serve", - builder: (yargs) => withNetworkOptions(yargs), + builder: (yargs) => + withNetworkOptions(yargs) + .option("register", { + type: "boolean", + default: false, + describe: + "run as the user's shared daemon: draw a secret, write ~/.redrob/run/server.json for other Redrob apps to find, exit when idle", + }) + .option("idle-exit", { + type: "number", + default: DEFAULT_IDLE_EXIT_MINUTES, + describe: "with --register: minutes with no attached app before exiting (0 = stay up)", + }), describe: "starts a headless redrob server", // Server loads instances per-request via x-redrob-directory header — no // need for an ambient project InstanceContext at startup. instance: false, handler: Effect.fn("Cli.serve")(function* (args) { const { Server } = yield* Effect.promise(() => import("../../server/server")) - if (!Flag.REDROB_SERVER_PASSWORD) { + const register = args.register === true + const Registry = register ? yield* Effect.promise(() => import("../../daemon/registry")) : undefined + // The daemon's secret is per start. It must be in the environment BEFORE the server's config + // layer reads it, and it is what goes into the record, so other apps can authenticate. + const secret = Registry ? Registry.newSecret() : undefined + if (secret) process.env.REDROB_SERVER_PASSWORD = secret + else if (!Flag.REDROB_SERVER_PASSWORD) { console.log("Warning: REDROB_SERVER_PASSWORD is not set; server is unsecured.") } const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) console.log(serverListeningLine(server)) + if (Registry && secret) { + const { setIdleExitAfterMs } = yield* Effect.promise(() => import("@redrob-code/server/handlers/clients")) + const Clients = yield* Effect.promise(() => import("@redrob-code/server/clients")) + const id = Registry.newID() + yield* Effect.promise(() => + Registry.write({ + id, + version: InstallationVersion, + url: `http://${server.hostname}:${server.port}`, + port: server.port, + pid: process.pid, + secret, + }), + ) + const idleExitMs = args["idle-exit"] > 0 ? args["idle-exit"] * 60_000 : undefined + setIdleExitAfterMs(idleExitMs) + + let leaving = false + const leave = (code: number) => { + if (leaving) return + leaving = true + void Registry.remove(id) + .catch(() => undefined) + .then(() => server.stop(true)) + .finally(() => process.exit(code)) + } + process.once("SIGTERM", () => leave(0)) + process.once("SIGINT", () => leave(0)) + + // Watch the record: a newer daemon that took the file over means this one should go. Watch + // the clients: once nobody has been attached for the idle window, go too. + const tick = setInterval(() => { + void Registry.read().then((current) => { + if (current && current.id !== id) return leave(0) + Clients.sweep() + const idle = Clients.idleFor() + if (idleExitMs !== undefined && idle !== undefined && idle >= idleExitMs) leave(0) + }) + }, 10_000) + tick.unref() + } + yield* Effect.never }), }) diff --git a/packages/redrob/src/cli/cmd/service.ts b/packages/redrob/src/cli/cmd/service.ts new file mode 100644 index 0000000000..872de23f59 --- /dev/null +++ b/packages/redrob/src/cli/cmd/service.ts @@ -0,0 +1,86 @@ +/** + * `redrob service start | status | stop`: the user's shared daemon, by hand. + * + * Apps do the same through the SDK's `connectOrStart`; this is the command line and what the + * launcher calls. `start --json` prints the record so a caller written in another language (the + * launcher is Rust) gets the url and secret without parsing prose. + */ +import { cmd } from "./cmd" +import { UI } from "@/cli/ui" +import { Daemon } from "@/daemon/connect" +import * as Registry from "@/daemon/registry" +import { DEFAULT_IDLE_EXIT_MINUTES } from "./serve" + +const print = (record: Registry.Record, json: boolean) => { + if (json) { + process.stdout.write(JSON.stringify(record) + "\n") + return + } + UI.println(`${UI.Style.TEXT_SUCCESS_BOLD}running${UI.Style.TEXT_NORMAL} ${record.url} (pid ${record.pid}, v${record.version})`) +} + +export const ServiceCommand = cmd({ + command: "service", + describe: "manage the shared background engine other Redrob apps attach to", + builder: (yargs) => + yargs + .command( + cmd({ + command: "start", + describe: "find the shared engine, or start it", + builder: (yargs) => + yargs + .option("json", { type: "boolean", default: false, describe: "print the record as one JSON line" }) + .option("idle-exit", { + type: "number", + default: DEFAULT_IDLE_EXIT_MINUTES, + describe: "when starting: minutes with no attached app before it exits (0 = stay up)", + }), + handler: async (args) => { + const connection = await Daemon.connectOrStart({ idleExitMinutes: args["idle-exit"] }) + print(connection.record, args.json) + }, + }), + ) + .command( + cmd({ + command: "status", + describe: "show whether the shared engine is running", + builder: (yargs) => yargs.option("json", { type: "boolean", default: false }), + handler: async (args) => { + const connection = await Daemon.current() + if (!connection) { + if (args.json) process.stdout.write(JSON.stringify(null) + "\n") + else UI.println(`${UI.Style.TEXT_DIM}not running${UI.Style.TEXT_NORMAL}`) + process.exitCode = 1 + return + } + print(connection.record, args.json) + }, + }), + ) + .command( + cmd({ + command: "stop", + describe: "stop the shared engine", + handler: async () => { + const stopped = await Daemon.stop() + UI.println(stopped ? "stopped" : `${UI.Style.TEXT_DIM}not running${UI.Style.TEXT_NORMAL}`) + }, + }), + ) + .command( + cmd({ + command: "restart", + describe: "stop the shared engine and start it again", + builder: (yargs) => yargs.option("json", { type: "boolean", default: false }), + handler: async (args) => { + await Daemon.stop() + const connection = await Daemon.connectOrStart() + print(connection.record, args.json) + }, + }), + ) + .demandCommand(), + handler: () => {}, +}) diff --git a/packages/redrob/src/config/config.ts b/packages/redrob/src/config/config.ts index 674896fec0..dcdcc78edd 100644 --- a/packages/redrob/src/config/config.ts +++ b/packages/redrob/src/config/config.ts @@ -397,6 +397,15 @@ const layer = Layer.effect( yield* Effect.logDebug("loaded custom config", { path: Flag.REDROB_CONFIG }) } + // App-managed config for this directory (see ConfigPaths.directoryConfigDir). Same + // position as REDROB_CONFIG, so the project's own files below still win. + for (const file of ConfigPaths.fileInDirectory(ConfigPaths.directoryConfigDir(ctx.directory), "redrob")) { + const next = yield* loadFile(file, authEnv) + if (Object.keys(next).length === 0) continue + yield* merge(file, next, "local") + yield* Effect.logDebug("loaded directory config", { path: file }) + } + if (!Flag.REDROB_DISABLE_PROJECT_CONFIG) { for (const file of yield* ConfigPaths.files("redrob", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge(file, yield* loadFile(file, authEnv), "local") diff --git a/packages/redrob/src/config/paths.ts b/packages/redrob/src/config/paths.ts index b4055290fc..9d9bfa3a42 100644 --- a/packages/redrob/src/config/paths.ts +++ b/packages/redrob/src/config/paths.ts @@ -1,6 +1,7 @@ export * as ConfigPaths from "./paths" import path from "path" +import { createHash } from "crypto" import { Flag } from "@redrob-code/core/flag/flag" import { Global } from "@redrob-code/core/global" import { unique } from "remeda" @@ -53,3 +54,26 @@ export function isProjectDirectory(dir: string) { export function fileInDirectory(dir: string, name: string) { return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] } + +/** + * Stable key for a project directory: the absolute, normalised path (lower-cased on Windows, + * where the filesystem is case-insensitive) hashed with SHA-256. Apps compute the same key to + * find `directoryConfigDir` without asking the engine. + */ +export function directoryKey(directory: string) { + let resolved = path.resolve(directory).replace(/[\\/]+$/, "") + if (process.platform === "win32") resolved = resolved.toLowerCase() + return createHash("sha256").update(resolved).digest("hex") +} + +/** + * Per-directory config written by an app on top of the engine (Redrob Cowork writes its + * workspace agents, plugins and MCP servers here), kept OUT of the project tree so it never + * collides with the user's own `.redrob/redrob.json` and never shows up in git. One shared + * daemon serves many apps and many directories, so this replaces the per-process + * REDROB_CONFIG an app used to pass to an engine it spawned itself. Loaded with the same + * precedence as REDROB_CONFIG: after the global config, before the project's own files. + */ +export function directoryConfigDir(directory: string) { + return path.join(Global.Path.state, "directories", directoryKey(directory)) +} diff --git a/packages/redrob/src/daemon/connect.ts b/packages/redrob/src/daemon/connect.ts new file mode 100644 index 0000000000..741005ca09 --- /dev/null +++ b/packages/redrob/src/daemon/connect.ts @@ -0,0 +1,165 @@ +/** + * Find the shared daemon or start it: what `redrob service start`, `redrob acp --attach` and every + * app do first. docs/LOCAL-ENGINE-API.md, "One daemon, not one per product". + * + * 1. A record whose daemon answers health with this binary's version: use it. + * 2. A record whose daemon answers but is another version: stop it (an upgrade restarts the + * daemon; attached apps re-read the record and reconnect). + * 3. A record whose pid is gone, or no record: start `redrob serve --register` detached and wait + * for the new record to answer. + */ +import { spawn } from "node:child_process" +import path from "node:path" +import { InstallationVersion } from "@redrob-code/core/installation/version" +import * as Registry from "./registry" + +export type Connection = { readonly url: string; readonly headers: { Authorization: string }; readonly record: Registry.Record } + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** The daemon named by the record, if it is alive, healthy and this version. */ +export async function current(env: NodeJS.ProcessEnv = process.env): Promise { + const record = await Registry.read(env) + if (!record) return undefined + if (!Registry.alive(record.pid)) { + await Registry.remove(record.id, env) + return undefined + } + if (!(await Registry.healthy(record))) return undefined + if (record.version !== InstallationVersion) return undefined + return { url: record.url, headers: Registry.headers(record.secret), record } +} + +/** How this very binary is launched again, so the daemon is the same build as the caller. */ +export function selfCommand(): { command: string; args: string[] } { + const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" + if (compiled) return { command: process.execPath, args: [] } + const entrypoint = process.argv[1] + if (!entrypoint) throw new Error("Failed to resolve the CLI entrypoint to start the daemon with") + return { command: process.execPath, args: [entrypoint] } +} + +export async function stop(env: NodeJS.ProcessEnv = process.env): Promise { + const record = await Registry.read(env) + if (!record) return false + // A stale record may name a pid another process now owns: only signal a daemon that answers + // with this record's own secret. + if (!Registry.alive(record.pid) || !(await Registry.healthy(record))) { + await Registry.remove(record.id, env) + return false + } + try { + process.kill(record.pid, "SIGTERM") + } catch { + /* already gone */ + } + for (let i = 0; i < 100 && Registry.alive(record.pid); i++) await sleep(50) + if (Registry.alive(record.pid)) { + try { + process.kill(record.pid, "SIGKILL") + } catch { + /* already gone */ + } + for (let i = 0; i < 100 && Registry.alive(record.pid); i++) await sleep(50) + } + await Registry.remove(record.id, env) + return true +} + +export type StartOptions = { + readonly env?: NodeJS.ProcessEnv + /** Minutes with no attached app before the daemon exits. 0 keeps it up. Default 10. */ + readonly idleExitMinutes?: number + readonly waitMs?: number +} + +export async function connectOrStart(options: StartOptions = {}): Promise { + const env = options.env ?? process.env + const found = await current(env) + if (found) return found + + const stale = await Registry.read(env) + if (stale && Registry.alive(stale.pid)) await stop(env) + + const self = selfCommand() + const idle = options.idleExitMinutes ?? 10 + const child = spawn(self.command, [...self.args, "serve", "--register", "--idle-exit", String(idle)], { + detached: true, + stdio: "ignore", + env, + }) + child.unref() + + const deadline = Date.now() + (options.waitMs ?? 15_000) + while (Date.now() < deadline) { + const record = await Registry.read(env) + if (record && (!stale || record.id !== stale.id) && (await Registry.healthy(record))) { + if (record.version !== InstallationVersion) { + throw new Error(`The daemon that started is version ${record.version}, this binary is ${InstallationVersion}`) + } + return { url: record.url, headers: Registry.headers(record.secret), record } + } + await sleep(100) + } + throw new Error("The Redrob Code daemon did not start in time") +} + +export type Attachment = { + readonly id: string + /** Detach and stop the heartbeat. Safe to call twice. */ + readonly detach: () => Promise +} + +/** + * Tell the daemon this process depends on it, and keep saying so: `POST /api/clients`, then a + * heartbeat every third of the daemon's TTL. The daemon exits once nobody has been attached for + * its idle window, so `redrob acp --attach` must hold one for as long as its host keeps it open -- + * connecting alone leaves the daemon counting idle time under a live session. Mirrors + * `attach()` in `@redrob-labs/sdk/daemon`. + */ +export async function attach( + connection: Connection, + app: string, + options: { readonly pid?: number; readonly fetch?: typeof fetch } = {}, +): Promise { + const fetchImpl = options.fetch ?? fetch + const base = connection.url.replace(/\/+$/, "") + const headers = { ...connection.headers, "content-type": "application/json" } + const body = JSON.stringify({ app, pid: options.pid ?? process.pid }) + const response = await fetchImpl(`${base}/api/clients`, { method: "POST", headers, body }) + if (!response.ok) throw new Error(`The engine refused the attachment (${response.status})`) + const client = (await response.json()) as { id: string } + const list = (await fetchImpl(`${base}/api/clients`, { headers: connection.headers }) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null)) as { ttlMs?: number } | null + const every = Math.max(5_000, Math.floor((list?.ttlMs ?? 90_000) / 3)) + let stopped = false + const beat = async () => { + if (stopped) return + const r = await fetchImpl(`${base}/api/clients/${client.id}/heartbeat`, { method: "POST", headers }).catch( + () => undefined, + ) + // 404: the daemon restarted (or dropped us); attach again so it keeps counting this app. + if (r && r.status === 404) { + const again = await fetchImpl(`${base}/api/clients`, { method: "POST", headers, body }).catch(() => undefined) + if (again?.ok) client.id = ((await again.json()) as { id: string }).id + } + } + const timer = setInterval(() => void beat(), every) + timer.unref?.() + return { + get id() { + return client.id + }, + detach: async () => { + if (stopped) return + stopped = true + clearInterval(timer) + await fetchImpl(`${base}/api/clients/${client.id}`, { method: "DELETE", headers: connection.headers }).catch( + () => undefined, + ) + }, + } +} + +export * as Daemon from "./connect" diff --git a/packages/redrob/src/daemon/registry.ts b/packages/redrob/src/daemon/registry.ts new file mode 100644 index 0000000000..1fa46d2b57 --- /dev/null +++ b/packages/redrob/src/daemon/registry.ts @@ -0,0 +1,118 @@ +/** + * The shared daemon's record: one `redrob serve --register` per user, found by every Redrob app. + * + * docs/LOCAL-ENGINE-API.md, section 3. The record lives at `$HOME/.redrob/run/server.json`, mode + * 0600, and is the auth boundary: it carries the Basic secret the daemon was started with. A fresh + * secret is drawn on every start, so a stale record never opens a running server, and an app that + * loses its connection re-reads the file rather than keeping a secret of its own. + * + * Nothing here is Effect: the same logic is read from the yargs commands, from `acp --attach`, and + * (as a copy of the read half) from the JS SDK, which cannot import this package. + */ +import { randomBytes, randomUUID } from "node:crypto" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +export type Record = { + /** Random per start; a client that saw a different id knows the daemon restarted. */ + readonly id: string + readonly version: string + readonly url: string + readonly port: number + readonly pid: number + /** The Basic password (username `redrob`). Mode 0600 is what keeps it private. */ + readonly secret: string +} + +export const USERNAME = "redrob" + +/** `$HOME/.redrob/run`, or `REDROB_RUN_DIR` for tests and odd homes. */ +export function directory(env: NodeJS.ProcessEnv = process.env): string { + return env.REDROB_RUN_DIR?.trim() || path.join(os.homedir(), ".redrob", "run") +} + +export function file(env: NodeJS.ProcessEnv = process.env): string { + return path.join(directory(env), "server.json") +} + +export function newSecret(): string { + return randomBytes(32).toString("base64url") +} + +export function newID(): string { + return randomUUID() +} + +export function headers(secret: string): { Authorization: string } { + return { Authorization: `Basic ${Buffer.from(`${USERNAME}:${secret}`).toString("base64")}` } +} + +function isRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false + const v = value as { [key: string]: unknown } + return ( + typeof v.id === "string" && + typeof v.version === "string" && + typeof v.url === "string" && + typeof v.port === "number" && + typeof v.pid === "number" && + v.pid > 0 && + typeof v.secret === "string" + ) +} + +/** The record on disk, or undefined when there is none or it does not parse. */ +export async function read(env: NodeJS.ProcessEnv = process.env): Promise { + try { + const value: unknown = JSON.parse(await fs.readFile(file(env), "utf8")) + return isRecord(value) ? value : undefined + } catch { + return undefined + } +} + +/** Write atomically (temp file + rename) with mode 0600. */ +export async function write(record: Record, env: NodeJS.ProcessEnv = process.env): Promise { + const target = file(env) + await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }) + const temp = `${target}.${record.id}.tmp` + await fs.writeFile(temp, JSON.stringify(record), { mode: 0o600 }) + await fs.rename(temp, target) +} + +/** Remove the record, but only if it is still ours: a newer daemon's record is left alone. */ +export async function remove(id: string | undefined, env: NodeJS.ProcessEnv = process.env): Promise { + const current = await read(env) + if (id !== undefined && current && current.id !== id) return + await fs.rm(file(env), { force: true }) +} + +export function alive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + // EPERM: the process exists but belongs to someone else. Still alive. + return (error as NodeJS.ErrnoException).code === "EPERM" + } +} + +/** Whether the daemon the record names answers `/api/health` with its own secret. */ +export async function healthy( + record: Record, + fetchImpl: typeof fetch = fetch, + timeoutMs = 2_000, +): Promise { + try { + const response = await fetchImpl(`${record.url.replace(/\/+$/, "")}/api/health`, { + headers: headers(record.secret), + signal: AbortSignal.timeout(timeoutMs), + }) + if (!response.ok) return false + const body = (await response.json().catch(() => null)) as { healthy?: unknown } | null + return body?.healthy === true + } catch { + return false + } +} diff --git a/packages/redrob/src/index.ts b/packages/redrob/src/index.ts index ad182ba3e8..bc82748ac8 100644 --- a/packages/redrob/src/index.ts +++ b/packages/redrob/src/index.ts @@ -8,10 +8,12 @@ import { AgentCommand } from "./cli/cmd/agent" import { UpgradeCommand } from "./cli/cmd/upgrade" import { UninstallCommand } from "./cli/cmd/uninstall" import { ModelsCommand } from "./cli/cmd/models" +import { ModelsLocalCommand } from "./cli/cmd/models-local" import { UI } from "./cli/ui" import { InstallationVersion } from "@redrob-code/core/installation/version" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" +import { ServiceCommand } from "./cli/cmd/service" import { DebugCommand } from "./cli/cmd/debug" import { StatsCommand } from "./cli/cmd/stats" import { McpCommand } from "./cli/cmd/mcp" @@ -90,7 +92,9 @@ const cli = yargs(args) .command(UpgradeCommand) .command(UninstallCommand) .command(ServeCommand) + .command(ServiceCommand) .command(ModelsCommand) + .command(ModelsLocalCommand) .command(StatsCommand) .command(ExportCommand) .command(ImportCommand) diff --git a/packages/redrob/src/provider/provider.ts b/packages/redrob/src/provider/provider.ts index 9cd0d1f0b2..65eeedf393 100644 --- a/packages/redrob/src/provider/provider.ts +++ b/packages/redrob/src/provider/provider.ts @@ -17,6 +17,7 @@ import { Env } from "../env" import { InstallationVersion } from "@redrob-code/core/installation/version" import { iife } from "@/util/iife" import { Global } from "@redrob-code/core/global" +import { SharedAccount } from "@redrob-code/core/shared-account" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema, Types } from "effect" @@ -211,7 +212,11 @@ function custom(dep: CustomDep): Record { const ok = hasKey || Boolean(yield* dep.auth(input.id)) || - Boolean((yield* dep.config()).provider?.["redrob"]?.options?.apiKey) + Boolean((yield* dep.config()).provider?.["redrob"]?.options?.apiKey) || + // Redrob Launcher's shared sign-in is a real credential too: with it present the SDK gets the + // key (merged in by `list` as source `launcher`), not the "public" placeholder a host app reads + // as "not connected". + Boolean(yield* Effect.promise(() => SharedAccount.apiKey())) // Without a credential the SDK still needs a non-empty apiKey to instantiate, so it gets a // placeholder. What used to happen here was that every model priced above 0 was deleted from @@ -1109,7 +1114,12 @@ export type Model = Types.DeepMutable> export const Info = Schema.Struct({ id: ProviderV2.ID, name: Schema.String, - source: Schema.Literals(["env", "config", "custom", "api"]), + /** + * Where the provider's credential came from. `launcher` is Redrob Launcher's shared `account.json`, + * the sign-in for the whole computer, used only when nothing of this app's own is present; a host app + * reads it off `GET /provider` to show "Signed in with Redrob Launcher". + */ + source: Schema.Literals(["env", "config", "custom", "api", "launcher"]), env: Schema.Array(Schema.String), key: optional(Schema.String), options: Schema.Record(Schema.String, Schema.Any), @@ -1745,6 +1755,18 @@ const layer = Layer.effect( } } + // load Redrob Launcher's shared account - the LAST fallback for the console key. Only when this + // app holds nothing of its own: no REDROB_API_KEY, no auth.json entry, no config apiKey. Read + // fresh on every list(), never cached, so a launcher sign-in or sign-out lands on the next call. + { + const providerID = ProviderV2.ID.redrob + const own = providers[providerID]?.key || cfg.provider?.["redrob"]?.options?.apiKey + if (!disabled.has(providerID) && !own) { + const shared = yield* Effect.promise(() => SharedAccount.apiKey()) + if (shared) mergeProvider(providerID, { source: "launcher", key: shared }) + } + } + // plugin auth loader - database now has entries for config providers for (const plugin of plugins) { if (!plugin.auth) continue @@ -2172,7 +2194,10 @@ const layer = Layer.effect( const configured = Object.keys(cfg.provider ?? {}) const provider = Object.values(s.providers).find((p) => configured.length === 0 || configured.includes(p.id)) if (!provider) return yield* new NoProvidersError() - const [model] = sort(Object.values(provider.models)) + const [model] = + provider.id === ProviderV2.ID.redrob && provider.models[REDROB_DEFAULT_MODEL] + ? [provider.models[REDROB_DEFAULT_MODEL]] + : sort(Object.values(provider.models)) if (!model) return yield* new NoModelsError({ providerID: provider.id }) return { providerID: provider.id, @@ -2186,9 +2211,27 @@ const layer = Layer.effect( const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] const smallModelFamilyPriority = ["gemini-flash", "gpt-nano", "claude-haiku"] +// An image-generation model answers a plain chat turn with an error, so it must never be picked as a +// default. "gemini-3-pro" in `priority` also matches "gemini-3-pro-image-preview", which made that +// model the default for the redrob provider and every first message failed with a 404. +// Capabilities are read when the caller passes full model records; bare {id} rows fall back to the id. +const IMAGE_MODEL_ID = /(^|[-/_.])image([-/_.]|$)/ +export function isChatModel(model: { + id: string + capabilities?: { output?: { text?: boolean; image?: boolean } } +}) { + const output = model.capabilities?.output + if (output?.text === false || output?.image === true) return false + return !IMAGE_MODEL_ID.test(model.id) +} + +// Redrob's own router. When the redrob provider lists it, it is the default. +export const REDROB_DEFAULT_MODEL = "auto" + export function sort(models: T[]) { return sortBy( models, + [(model) => (isChatModel(model) ? 0 : 1), "asc"], [(model) => priority.findIndex((filter) => model.id.includes(filter)), "desc"], [(model) => (model.id.includes("latest") ? 0 : 1), "asc"], [(model) => model.id, "desc"], diff --git a/packages/redrob/src/redrob/transcription.ts b/packages/redrob/src/redrob/transcription.ts new file mode 100644 index 0000000000..0273e80a73 --- /dev/null +++ b/packages/redrob/src/redrob/transcription.ts @@ -0,0 +1,163 @@ +import { Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { Auth } from "@/auth" +import { gatewayMessage, gatewayUrl, redrobKey } from "@/tool/image_generate" + +/** + * Transcription through the Redrob gateway's `POST /v1/audio/transcriptions`, on the engine's own + * Redrob credential, for the reason speech gives: the key is the engine's and stays here. Its caller + * is the engine's `POST /redrob/transcribe` route, which is how Redrob Cowork's push-to-talk turns a + * recording into text without the app ever holding the key. + * + * Everything that can be refused is refused before anything is sent, so a bad request costs nothing. + */ + +/** Scribe handles Korean, English and the two mixed in one sentence, which is how users speak. */ +export const DEFAULT_TRANSCRIPTION_MODEL = "elevenlabs/scribe-v2" + +/** The formats the gateway accepts, which are the ones its upstream documents. */ +export const TRANSCRIPTION_FORMATS = ["wav", "mp3", "flac", "m4a", "ogg", "webm", "aac"] as const +export type TranscriptionFormat = (typeof TRANSCRIPTION_FORMATS)[number] + +/** The gateway's limit for one request, decoded, checked here so an over-size clip costs nothing. */ +export const MAX_TRANSCRIPTION_BYTES = 10 * 1024 * 1024 + +const TIMEOUT_MS = 120_000 +const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/ +const LANGUAGE = /^[a-z]{2}$/ + +/** + * Why audio could not be transcribed. `invalid` and `no_key` are found before anything is sent and + * cost nothing; `upstream` and `timeout` are the gateway's. + */ +export class TranscriptionError extends Schema.TaggedErrorClass()("TranscriptionError", { + reason: Schema.Literals(["invalid", "no_key", "upstream", "timeout"]), + message: Schema.String, + status: Schema.optional(Schema.Number), +}) {} + +export type PreparedTranscription = { + audio: string + format: TranscriptionFormat + model: string + language?: string + bytes: number +} + +export type Transcript = { text: string; seconds?: number; costUsd?: number } + +/** What base64 decodes to, without decoding it: three bytes per four characters, less the padding. */ +function decodedBytes(base64: string): number { + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0 + return (base64.length / 4) * 3 - padding +} + +/** The request as it will be sent, or why it would be refused, with nothing sent either way. */ +export const prepare = Effect.fn("RedrobTranscription.prepare")(function* (input: { + audio: string + format: TranscriptionFormat + model?: string + language?: string +}) { + const audio = input.audio.replace(/\s+/g, "") + if (!audio) return yield* new TranscriptionError({ reason: "invalid", message: "Give the audio to transcribe." }) + if (audio.length % 4 !== 0 || !BASE64.test(audio)) { + return yield* new TranscriptionError({ + reason: "invalid", + message: "The audio must be base64: the raw bytes, not a data URL.", + }) + } + const bytes = decodedBytes(audio) + if (bytes > MAX_TRANSCRIPTION_BYTES) { + return yield* new TranscriptionError({ + reason: "invalid", + message: `The audio is ${bytes} bytes; transcribe at most ${MAX_TRANSCRIPTION_BYTES} per call.`, + }) + } + const language = input.language?.trim().toLowerCase() || undefined + if (language && !LANGUAGE.test(language)) { + return yield* new TranscriptionError({ + reason: "invalid", + message: `"${input.language}" is not an ISO-639-1 language such as "ko" or "en".`, + }) + } + const prepared: PreparedTranscription = { + audio, + format: input.format, + model: input.model?.trim() || DEFAULT_TRANSCRIPTION_MODEL, + ...(language ? { language } : {}), + bytes, + } + return prepared +}) + +/** The engine's Redrob key, or a `no_key` failure, which is found before anything is sent. */ +export const key = Effect.fn("RedrobTranscription.key")(function* (auth: Auth.Interface) { + const found = redrobKey(yield* auth.get("redrob").pipe(Effect.orElseSucceed(() => undefined))) + if (!found) { + return yield* new TranscriptionError({ + reason: "no_key", + message: "Connect Redrob to transcribe speech: no Redrob API key is available.", + }) + } + return found +}) + +const GatewayTranscript = Schema.Struct({ + text: Schema.String, + seconds: Schema.optional(Schema.Number), + costUsd: Schema.optional(Schema.Number), +}) +const decodeTranscript = Schema.decodeUnknownEffect(Schema.fromJsonString(GatewayTranscript)) + +/** One call to the gateway. The answer is checked for being a transcript before anyone reads it. */ +export const request = Effect.fn("RedrobTranscription.request")(function* ( + http: HttpClient.HttpClient, + key: string, + transcription: PreparedTranscription, +) { + const outgoing = yield* HttpClientRequest.post(`${gatewayUrl()}/audio/transcriptions`).pipe( + HttpClientRequest.bearerToken(key), + HttpClientRequest.bodyJson({ + model: transcription.model, + input_audio: { data: transcription.audio, format: transcription.format }, + response_format: "json", + ...(transcription.language ? { language: transcription.language } : {}), + }), + Effect.orDie, + ) + const response = yield* http.execute(outgoing).pipe( + Effect.mapError( + () => new TranscriptionError({ reason: "upstream", message: "The Redrob gateway could not be reached." }), + ), + Effect.timeoutOrElse({ + duration: TIMEOUT_MS, + orElse: () => Effect.fail(new TranscriptionError({ reason: "timeout", message: "Transcription timed out." })), + }), + ) + const body = yield* response.text.pipe( + Effect.mapError( + () => new TranscriptionError({ reason: "upstream", message: "The Redrob gateway's answer was cut off." }), + ), + ) + if (response.status < 200 || response.status >= 300) { + return yield* new TranscriptionError({ + reason: "upstream", + status: response.status, + message: `The Redrob gateway answered ${response.status}: ${gatewayMessage(body)}`, + }) + } + const parsed = yield* decodeTranscript(body).pipe( + Effect.mapError( + () => new TranscriptionError({ reason: "upstream", message: "The Redrob gateway answered without a transcript." }), + ), + ) + const transcript: Transcript = { + text: parsed.text.trim(), + ...(parsed.seconds !== undefined && Number.isFinite(parsed.seconds) ? { seconds: parsed.seconds } : {}), + ...(parsed.costUsd !== undefined && Number.isFinite(parsed.costUsd) ? { costUsd: parsed.costUsd } : {}), + } + return transcript +}) + +export * as RedrobTranscription from "./transcription" diff --git a/packages/redrob/src/server/routes/instance/httpapi/api.ts b/packages/redrob/src/server/routes/instance/httpapi/api.ts index 8010729d18..93418db36e 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/api.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/api.ts @@ -28,6 +28,7 @@ import { WorkspaceApi } from "./groups/workspace" import { makeApi } from "@redrob-code/protocol/api" import { LocationMiddleware } from "@redrob-code/server/location" import { SessionLocationMiddleware } from "@redrob-code/server/middleware/session-location" +import { ChatCompletionChunk } from "@redrob-code/protocol/groups/chat-completion" import { GlobalApi } from "./groups/global" import { Authorization } from "./middleware/authorization" import { SchemaErrorMiddleware } from "./middleware/schema-error" @@ -91,6 +92,11 @@ export const RedrobHttpApi = HttpApi.make("redrob") Integration.Method, Integration.Ref, SkillV2.Source, + // Not referenced by any endpoint's declared success type -- the streaming shape is + // patched onto /v1/chat/completions in public.ts, because HttpApi cannot express "JSON + // or SSE depending on a request field". Registering it here is what makes that patch's + // $ref resolve instead of dangling in the generated spec. + ChatCompletionChunk, ]) export type RootHttpApiType = typeof RootHttpApi diff --git a/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts b/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts index 11c5315093..c3598bcfcb 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/groups/control.ts @@ -1,9 +1,11 @@ import { Auth } from "@/auth" import { Schema } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { InvalidRequestError, ServiceUnavailableError, TimeoutError, UpstreamError } from "../errors" import { described } from "./metadata" import { ProviderV2 } from "@redrob-code/core/provider" +import { TRANSCRIPTION_FORMATS } from "@/redrob/transcription" const AuthParams = Schema.Struct({ providerID: ProviderV2.ID, @@ -28,9 +30,36 @@ export const LogInput = Schema.Struct({ }), }) +export const SpeechInput = Schema.Struct({ + text: Schema.String.annotate({ description: "The words to speak, at most 4096 characters" }), + voice: Schema.optional(Schema.String).annotate({ description: "A voice of the chosen model" }), + model: Schema.optional(Schema.String).annotate({ + description: "A speech model id served by the Redrob gateway. Defaults to elevenlabs/eleven-flash-v2.5, for playback.", + }), +}) + +export const TranscribeInput = Schema.Struct({ + audio: Schema.String.annotate({ description: "Base64-encoded audio, the raw bytes rather than a data URL, at most 10 MB" }), + format: Schema.Literals(TRANSCRIPTION_FORMATS).annotate({ description: "The audio's container format" }), + language: Schema.optional(Schema.String).annotate({ + description: 'ISO-639-1 language, e.g. "ko". Detected when omitted.', + }), + model: Schema.optional(Schema.String).annotate({ + description: "A transcription model id served by the Redrob gateway", + }), +}) + +export const TranscribeOutput = Schema.Struct({ + text: Schema.String.annotate({ description: "The transcript, trimmed; empty when nothing was said" }), + seconds: Schema.optional(Schema.Number).annotate({ description: "Seconds of audio billed" }), + costUsd: Schema.optional(Schema.Number).annotate({ description: "What the request cost, in USD" }), +}) + export const ControlPaths = { auth: "/auth/:providerID", log: "/log", + speech: "/redrob/speech", + transcribe: "/redrob/transcribe", } as const export const ControlApi = HttpApi.make("control").add( @@ -59,6 +88,33 @@ export const ControlApi = HttpApi.make("control").add( description: "Remove authentication credentials", }), ), + /* + * Speech on the engine's own Redrob credential, for a client that has to play audio and must + * not hold the key: Redrob Cowork's read-aloud. The key never leaves this process. + */ + HttpApiEndpoint.post("speech", ControlPaths.speech, { + payload: SpeechInput, + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array({ contentType: "audio/mpeg" })), + error: [InvalidRequestError, ServiceUnavailableError, UpstreamError, TimeoutError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "redrob.speech", + summary: "Speak text", + description: "Turn text into mp3 speech through the Redrob gateway, billed to the connected Redrob account.", + }), + ), + /* Transcription on the same credential, for Redrob Cowork's push-to-talk, for the same reason. */ + HttpApiEndpoint.post("transcribe", ControlPaths.transcribe, { + payload: TranscribeInput, + success: described(TranscribeOutput, "The transcript"), + error: [InvalidRequestError, ServiceUnavailableError, UpstreamError, TimeoutError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "redrob.transcribe", + summary: "Transcribe speech", + description: "Turn recorded speech into text through the Redrob gateway, billed to the connected Redrob account.", + }), + ), HttpApiEndpoint.post("log", ControlPaths.log, { query: LogQuery, payload: LogInput, diff --git a/packages/redrob/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/redrob/src/server/routes/instance/httpapi/groups/experimental.ts index 6101ac16cc..aaf5431fbd 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/groups/experimental.ts @@ -25,8 +25,30 @@ const ConsoleStateResponse = Schema.Struct({ switchableOrgCount: NonNegativeInt, }).annotate({ identifier: "ConsoleState" }) +/** + * What this engine's `/v1/chat/completions` supports. + * + * Declared as a struct rather than a version number so a caller negotiates a + * capability instead of pinning an engine build: a downstream app that needs + * caller-owned tools checks `callerTools`, and an older engine that lacks the whole + * route simply omits `chatCompletions`. `version` is the contract's revision, not + * the engine's. + */ +const ChatCompletionsCapability = Schema.Struct({ + version: NonNegativeInt, + callerTools: Schema.Boolean, + stream: Schema.Boolean, +}).annotate({ identifier: "ChatCompletionsCapability" }) + +/** The shared daemon contract: `redrob serve --register`, `~/.redrob/run/server.json`, `/api/clients`. */ +const SharedDaemonCapability = Schema.Struct({ + version: NonNegativeInt, +}).annotate({ identifier: "SharedDaemonCapability" }) + const CapabilitiesResponse = Schema.Struct({ backgroundSubagents: Schema.Boolean, + chatCompletions: ChatCompletionsCapability, + sharedDaemon: SharedDaemonCapability, }).annotate({ identifier: "ExperimentalCapabilities" }) const ConsoleOrgOption = Schema.Struct({ diff --git a/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts b/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts index e120749f18..07a2f7079b 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/handlers/control.ts @@ -3,12 +3,49 @@ import { Auth } from "@/auth" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { RootHttpApi } from "../api" -import { LogInput } from "../groups/control" +import { LogInput, SpeechInput, TranscribeInput } from "../groups/control" +import { RedrobTranscription, type TranscriptionError } from "@/redrob/transcription" +import { InvalidRequestError, ServiceUnavailableError, TimeoutError, UpstreamError } from "../errors" +import { PLAYBACK_DEFAULTS, prepareSpeech, requestSpeech, speechKey, type SpeechError } from "@/tool/speech_generate" +import { HttpClient } from "effect/unstable/http" import { ProviderV2 } from "@redrob-code/core/provider" export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) => Effect.gen(function* () { const auth = yield* Auth.Service + const http = yield* HttpClient.HttpClient + + /** + * Each gateway failure as the endpoints declare it; no_key is 503 because the engine, not the + * caller, lacks it. Speech and transcription fail for the same four reasons. + */ + const gatewayFailure = (operation: string) => (error: SpeechError | TranscriptionError) => + error.reason === "invalid" + ? new InvalidRequestError({ message: error.message }) + : error.reason === "no_key" + ? new ServiceUnavailableError({ message: error.message, service: "redrob" }) + : error.reason === "timeout" + ? new TimeoutError({ message: error.message, operation }) + : new UpstreamError({ + message: error.message, + service: "redrob", + ...(error.status ? { status: error.status } : {}), + }) + + const speech = Effect.fn("ControlHttpApi.speech")(function* (ctx: { payload: typeof SpeechInput.Type }) { + // This route is for audio that is played at once, so it defaults to the fast playback model. + const prepared = yield* prepareSpeech(ctx.payload, PLAYBACK_DEFAULTS) + const key = yield* speechKey(auth) + return (yield* requestSpeech(http, key, prepared)).audio + }, Effect.mapError(gatewayFailure("speech"))) + + const transcribe = Effect.fn("ControlHttpApi.transcribe")(function* (ctx: { + payload: typeof TranscribeInput.Type + }) { + const prepared = yield* RedrobTranscription.prepare(ctx.payload) + const key = yield* RedrobTranscription.key(auth) + return yield* RedrobTranscription.request(http, key, prepared) + }, Effect.mapError(gatewayFailure("transcribe"))) const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: { params: { providerID: ProviderV2.ID } @@ -38,6 +75,11 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han return true }) - return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log) + return handlers + .handle("authSet", authSet) + .handle("authRemove", authRemove) + .handle("speech", speech) + .handle("transcribe", transcribe) + .handle("log", log) }), ) diff --git a/packages/redrob/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/redrob/src/server/routes/instance/httpapi/handlers/experimental.ts index b218c6040d..1661838f69 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -37,7 +37,17 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const flags = yield* RuntimeFlags.Service const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () { - return { backgroundSubagents: flags.experimentalBackgroundSubagents } + return { + backgroundSubagents: flags.experimentalBackgroundSubagents, + // Declared so a caller can negotiate instead of pinning an engine version. + // A downstream app reads this, sees what this engine's `/v1/chat/completions` + // supports, and degrades explicitly rather than discovering a gap by getting + // a 400 mid-feature. `version` is the contract revision, not the engine's. + chatCompletions: { version: 1, callerTools: true, stream: true }, + // One shared daemon per user (`redrob serve --register`, `/api/clients`). An app that sees this + // attaches instead of starting an engine of its own. + sharedDaemon: { version: 1 }, + } }) const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { diff --git a/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts index 66b44f762b..b663577e9d 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,4 +1,5 @@ import { ServerAuth } from "@/server/auth" +import * as Clients from "@redrob-code/server/clients" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" @@ -40,6 +41,7 @@ function validateCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, config: ServerAuth.Info, + path: string, ) { return Effect.gen(function* () { if (!ServerAuth.required(config)) return yield* effect @@ -49,7 +51,8 @@ function validateCredential( ) return yield* new HttpApiError.Unauthorized({}) } - return yield* effect + // The shared daemon's idle clock: an authorized request is someone needing the engine. + return yield* Clients.withActivity(path, effect) }) } @@ -85,6 +88,7 @@ function validateRawCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, config: ServerAuth.Info, + path: string, ) { if (!ServerAuth.required(config)) return effect if (!ServerAuth.authorized(credential, config)) @@ -94,7 +98,7 @@ function validateRawCredential( headers: { "www-authenticate": WWW_AUTHENTICATE }, }), ) - return effect + return Clients.withActivity(path, effect) } export const authorizationRouterMiddleware = HttpRouter.middleware()( @@ -107,7 +111,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( const request = yield* HttpServerRequest.HttpServerRequest const url = new URL(request.url, "http://localhost") return yield* credentialFromURL(url, request).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), + Effect.flatMap((credential) => validateRawCredential(effect, credential, config, url.pathname)), ) }) }), @@ -121,8 +125,9 @@ export const authorizationLayer = Layer.effect( return Authorization.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest + const path = new URL(request.url, "http://localhost").pathname return yield* credentialFromRequest(request).pipe( - Effect.flatMap((credential) => validateCredential(effect, credential, config)), + Effect.flatMap((credential) => validateCredential(effect, credential, config, path)), ) }), ) @@ -140,7 +145,7 @@ export const ptyConnectAuthorizationLayer = Layer.effect( const url = new URL(request.url, "http://localhost") if (hasPtyConnectTicketURL(url)) return yield* effect return yield* credentialFromURL(url, request).pipe( - Effect.flatMap((credential) => validateCredential(effect, credential, config)), + Effect.flatMap((credential) => validateCredential(effect, credential, config, url.pathname)), ) }), ) diff --git a/packages/redrob/src/server/routes/instance/httpapi/public.ts b/packages/redrob/src/server/routes/instance/httpapi/public.ts index 27d43b226c..9c64dd3729 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/public.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/public.ts @@ -169,6 +169,28 @@ function matchLegacyOpenApi(input: Record) { }, } } + if (path === "/v1/chat/completions" && method === "post") { + // Same gap, and worse consequences here: this route answers with JSON or with + // text/event-stream depending on `stream`, and the declared success schema only + // describes the JSON. A generated client built from the unpatched spec looks for + // choices[].message.content on a streaming response and finds nothing on every + // frame, because a chunk carries choices[].delta instead. + // + // Both content types are declared on the one 200 so the spec says what actually + // happens, rather than replacing the JSON shape and lying in the other direction. + const existing = operation.responses?.["200"] as + | { description?: string; content?: Record } + | undefined + operation.responses!["200"] = { + description: + "Chat completion. `application/json` when `stream` is false or absent; " + + "`text/event-stream` of `ChatCompletionChunk` frames terminated by `data: [DONE]` when true.", + content: { + ...(existing?.content ?? {}), + "text/event-stream": { schema: { $ref: "#/components/schemas/ChatCompletionChunk" } }, + }, + } + } const route = `${method.toUpperCase()} ${path}` for (const param of operation.parameters ?? []) normalizeParameter(param, route) } diff --git a/packages/redrob/src/server/routes/instance/httpapi/server.ts b/packages/redrob/src/server/routes/instance/httpapi/server.ts index f2fe99aaa5..81476d3a4d 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/server.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/server.ts @@ -53,7 +53,7 @@ import { MoveSession } from "@redrob-code/core/control-plane/move-session" import { Database } from "@redrob-code/core/database/database" import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1" import { LayerNode } from "@redrob-code/core/effect/layer-node" -import { httpClient } from "@redrob-code/core/effect/app-node-platform" +import { httpClient, llmClient } from "@redrob-code/core/effect/app-node-platform" import { EventV2 } from "@redrob-code/core/event" import { ModelsDev } from "@redrob-code/core/models-dev" import { Npm } from "@redrob-code/core/npm" @@ -249,6 +249,10 @@ const app = LayerNode.group([ SessionShare.node, InstanceStore.node, httpClient, + // `/v1/chat/completions` (handlers from @redrob-code/server) calls the model with no session in + // between, so it needs the LLM client in the route tree itself; only group members are exposed + // to handlers. Missing in v0.6.4: every call died with "Service not found: @redrob/LLMClient". + llmClient, EventV2.node, ProjectV2.node, ProjectCopy.node, diff --git a/packages/redrob/src/session/prompt.ts b/packages/redrob/src/session/prompt.ts index b9d6c23ee7..2eb8313f96 100644 --- a/packages/redrob/src/session/prompt.ts +++ b/packages/redrob/src/session/prompt.ts @@ -1292,11 +1292,33 @@ const layer = Layer.effect( sys.mcp(agent, session.permission), MessageV2.toModelMessagesEffect(msgs, model), ]) + /* + * K-2's arming, finally called. + * + * The prompt text is every text part of the user's last message joined, not + * just the first: a message with an attachment between two sentences would + * otherwise arm on half of what the user said. + * + * The URL is deliberately NOT fetched. Arming by URL glob needs the active + * tab, and the only way to get it is a browser request -- a round trip on + * every single message, which with no extension attached spends the request + * deadline before the model sees anything. A twenty second pause before each + * reply is a worse product than URL arming is a better one. Keywords arm + * today; the URL half arms once a session carries its tab URL as state the + * engine already holds, rather than as a question it has to ask. + */ + const armedSkills = yield* sys.armedSkills(agent, { + prompt: (lastUserMsg?.parts ?? []) + .filter((part) => part.type === "text") + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n"), + }) const system = [ ...env, ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : []), + ...(armedSkills ? [armedSkills] : []), ANSWER_OPTIONS_PROMPT, ] const format = lastUser.format ?? { type: "text" as const } diff --git a/packages/redrob/src/session/system.ts b/packages/redrob/src/session/system.ts index bde67ebc91..f1e022937c 100644 --- a/packages/redrob/src/session/system.ts +++ b/packages/redrob/src/session/system.ts @@ -17,6 +17,8 @@ import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" import { Permission } from "@/permission" import { Skill } from "@/skill" +import { SkillArming } from "@redrob-code/core/skill/arming" +import { escapeHtml } from "@/util/html" import { AbsolutePath } from "@redrob-code/core/schema" import { Location } from "@redrob-code/core/location" import { LocationServiceMap, locationServiceMapLayer } from "@redrob-code/core/location-services" @@ -51,6 +53,19 @@ export function provider(model: Provider.Model) { export interface Interface { readonly environment: (model: Provider.Model) => Effect.Effect readonly skills: (agent: Agent.Info) => Effect.Effect + /** + * The bodies of the skills this prompt armed, as their own block. + * + * Separate from `skills` because the two make different claims. `skills` is the + * discovery list -- names and descriptions, for the model to choose from with the skill + * tool. This is the content of the ones that armed themselves on what the user just + * typed, or on the page they are looking at, which the model did not ask for and must + * therefore be told it is reading. + */ + readonly armedSkills: ( + agent: Agent.Info, + input: { readonly prompt: string; readonly url?: string | undefined }, + ) => Effect.Effect readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect } @@ -116,6 +131,58 @@ const layer = Layer.effect( ].join("\n") }), + /** + * Arm the skills whose own `autoInject` block matches, and hand over their bodies. + * + * K-2 landed the matcher as a pure function and nothing called it, so every skill + * that declared keywords and URL globs behaved exactly like one that declared none. + * This is the call. + * + * Two properties are deliberate and both are about not drowning the prompt. A skill + * with no `autoInject` never arms, so the shipped `skill-writer` stays + * ask-for-it-by-name. And the armed bodies are labelled as armed, with the patterns + * that armed them: a model handed a document toolchain it never requested should be + * able to see why, and so should anyone reading the transcript. + */ + armedSkills: Effect.fn("SystemPrompt.armedSkills")(function* ( + agent: Agent.Info, + input: { readonly prompt: string; readonly url?: string | undefined }, + ) { + if (Permission.disabled(["skill"], agent.permission).has("skill")) return + + const list = yield* skill.available(agent) + const armed = SkillArming.arm({ skills: list, prompt: input.prompt, url: input.url }) + if (armed.length === 0) return + + const byName = new Map(list.map((entry) => [entry.name, entry] as const)) + const sections: string[] = [] + for (const entry of armed) { + const info = byName.get(entry.name) + // `arm` only ever returns names it was given, so a miss here is impossible rather + // than unlikely -- but a skill with no content would silently contribute an empty + // section, which reads as a skill that said nothing. + if (info === undefined || info.content.trim().length === 0) continue + const why = [ + ...entry.keywords.map((keyword) => `keyword ${keyword}`), + ...entry.urls.map((glob) => `url ${glob}`), + ].join(", ") + // Escaped with the repo's own helper: a keyword or URL glob is author-supplied + // text from a skill file, and the first version of this used `JSON.stringify`, + // which put a raw `"` inside the attribute and broke the tag. + sections.push(` `, info.content, ` `) + } + if (sections.length === 0) return + + return [ + "", + "These skills armed themselves on this request -- their own autoInject block matched", + "what the user typed, or the page they are on. You did not load them with the skill", + "tool; they are here already, so do not load them again.", + ...sections, + "", + ].join("\n") + }), + mcp: Effect.fn("SystemPrompt.mcp")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) { const ruleset = Permission.merge(agent.permission, permission ?? []) const instructions = (yield* mcp.instructions()).filter( diff --git a/packages/redrob/src/skill/index.ts b/packages/redrob/src/skill/index.ts index 53a7cf4368..191f81b9f0 100644 --- a/packages/redrob/src/skill/index.ts +++ b/packages/redrob/src/skill/index.ts @@ -1,12 +1,13 @@ import { LayerNode } from "@redrob-code/core/effect/layer-node" import path from "path" -import { Effect, Layer, Context, Schema } from "effect" +import { Effect, Layer, Context, Option, Schema } from "effect" import { NamedError } from "@redrob-code/core/util/error" import type { Agent } from "@/agent/agent" import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" import { Global } from "@redrob-code/core/global" import { SkillPlugin } from "@redrob-code/core/plugin/skill" +import { SkillV2 } from "@redrob-code/core/skill" import { Permission } from "@/permission" import { FSUtil } from "@redrob-code/core/fs-util" import { Config } from "@/config/config" @@ -39,6 +40,15 @@ export const Info = Schema.Struct({ description: Schema.optional(Schema.String), location: Schema.String, content: Schema.String, + /** + * The skill's own auto-arming block, when it declares one. + * + * Carried here because this is the skill surface the system prompt reads. K-2 landed the + * matcher and the frontmatter key, but this `Info` dropped the field, so arming had no + * data to work on no matter who called it -- every skill looked like a skill that + * declared nothing. Same schema as the loader's, not a second copy. + */ + autoInject: Schema.optional(SkillV2.AutoInject), }) export type Info = Schema.Schema.Type @@ -50,7 +60,14 @@ const Issue = Schema.StructWithRest( [Schema.Record(Schema.String, Schema.Unknown)], ) -function isSkillFrontmatter(data: unknown): data is { name: string; description?: string } { +/** + * Does this frontmatter block carry what a skill needs? + * + * `autoInject` is checked but NOT required: a malformed block costs only the arming + * behaviour, and the skill still loads and stays explicitly selectable. Refusing the whole + * file would lose a working skill over an optional key. + */ +function isSkillFrontmatter(data: unknown): data is { name: string; description?: string; autoInject?: unknown } { return ( isRecord(data) && typeof data.name === "string" && @@ -58,6 +75,17 @@ function isSkillFrontmatter(data: unknown): data is { name: string; description? ) } +/** + * Decode an `autoInject` block, keeping the skill when it is malformed. + * + * Returns undefined for anything the schema rejects, which makes the skill behave exactly + * like one that declared no block at all -- the documented failure mode. + */ +function autoInjectOf(data: { autoInject?: unknown }): SkillV2.AutoInject | undefined { + if (data.autoInject === undefined) return undefined + return Schema.decodeUnknownOption(SkillV2.AutoInject)(data.autoInject).pipe(Option.getOrUndefined) +} + export class InvalidError extends Schema.TaggedErrorClass()("SkillInvalidError", { path: Schema.String, message: Schema.optional(Schema.String), @@ -136,6 +164,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev description: md.data.description, location: match, content: md.content, + autoInject: autoInjectOf(md.data), } }) diff --git a/packages/redrob/src/tool/code-mode.ts b/packages/redrob/src/tool/code-mode.ts index 98df376495..57eea30cfa 100644 --- a/packages/redrob/src/tool/code-mode.ts +++ b/packages/redrob/src/tool/code-mode.ts @@ -8,6 +8,18 @@ import { Agent } from "@/agent/agent" import { Session } from "@/session/session" import { Permission } from "@/permission" import { Plugin } from "@/plugin" +import { + DOMAIN_GLOBALS, + bridgedPage, + domainTools, + sessionChannel, + unavailableChannel, + unavailablePage, +} from "./domain" +import { BrowserRequestV1 } from "@redrob-code/core/browser-request" +import * as Documents from "./document" +import { InstanceRef } from "@/effect/instance-ref" +import { assertExternalDirectoryEffect } from "./external-directory" export const CODE_MODE_TOOL = "execute" @@ -57,10 +69,16 @@ function groupByServer(mcpTools: Record, servers: readonly export function describeCatalog(mcpTools: Record, servers: readonly string[]): string { return CodeMode.make({ - tools: toolTree( - [...groupByServer(mcpTools, servers).values()].flat(), - () => () => Effect.fail(toolError("Tool preview is not executable.")), - ), + tools: { + ...toolTree( + [...groupByServer(mcpTools, servers).values()].flat(), + () => () => Effect.fail(toolError("Tool preview is not executable.")), + ), + // The preview must list the same domain globals a live execution binds, so the + // description a model reads matches the scope it will actually run in. + ...domainTools({ page: unavailablePage(), channel: unavailableChannel() }), + }, + globals: DOMAIN_GLOBALS, }).instructions() } @@ -192,6 +210,10 @@ export const CodeModeTool = Tool.define( const agents = yield* Agent.Service const sessions = yield* Session.Service const plugin = yield* Plugin.Service + // Resolved here with the other location services, not inside `execute`: the tool + // contract requires `execute` to need nothing from the context, so pulling the service + // per call would put it back in the effect's requirements and fail to typecheck. + const browser = yield* BrowserRequestV1.Service const init: Tool.DefWithoutID = { description: DESCRIPTION, @@ -213,6 +235,42 @@ export const CodeModeTool = Tool.define( const calls: CallEntry[] = [] const attachments: Attachment[] = [] + // K-1 domain objects for this execution. `channel` posts into the session that owns + // the run; `page` (PA-10) resolves through the browser channel — each call is + // published as a pending request on the event stream and settled by the attached + // browser client. With no client attached the request expires on the service's + // deadline and the call refuses by name, which is the same outcome the earlier + // `unavailablePage` gave and for a reason the message states. + const page = bridgedPage({ browser, sessionID: ctx.sessionID }) + const channel = sessionChannel({ sessions, sessionID: ctx.sessionID, messageID: ctx.messageID }) + // K-3 document objects. Unlike `page`, these DO work in the engine process: a write + // lands a real file. The guard is the same external-directory check the `write` tool + // uses, so a program cannot drop a document outside the instance directory without + // the user being asked. + // + // `InstanceRef` is read directly rather than through `InstanceState.context`, which + // DIES when no instance is bound. A code-mode execution with no instance is a real + // configuration — the tool is reachable from harnesses that bind none — and the right + // answer there is to resolve relative paths against the process directory, not to + // kill the tool over a document global the program may never touch. + const instance = yield* InstanceRef + const documents = Documents.documents({ + directory: instance?.directory ?? process.cwd(), + guard: (filepath) => + assertExternalDirectoryEffect(ctx, filepath).pipe( + Effect.asVoid, + Effect.catchCause((cause) => { + const error = Cause.squash(cause) + return Effect.fail( + new Documents.DocumentFailureError({ + object: "document", + operation: "write", + reason: `writing ${filepath} was not permitted: ${error instanceof Error ? error.message : String(error)}`, + }), + ) + }), + ), + }) const publish = () => ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) @@ -237,7 +295,11 @@ export const CodeModeTool = Tool.define( ) const runtime = CodeMode.make({ - tools: toolTree(catalog, callTool), + // Domain namespaces are spread last deliberately: a connected MCP server named + // `page` must not shadow the typed domain object a skill document is written + // against. The domain objects are part of the language; the MCP catalog is not. + tools: { ...toolTree(catalog, callTool), ...domainTools({ page, channel, documents }) }, + globals: DOMAIN_GLOBALS, onToolCallStart: ({ index, name, input }) => Effect.suspend(() => { const shown = (() => { diff --git a/packages/redrob/src/tool/document.ts b/packages/redrob/src/tool/document.ts new file mode 100644 index 0000000000..563d3e4dd6 --- /dev/null +++ b/packages/redrob/src/tool/document.ts @@ -0,0 +1,930 @@ +/** + * K-3: a real document toolchain in code mode — `docx`, `xlsx`, `pptx` and `pdf` as typed + * globals in the same shape as K-1's `page`, each with a read half and a write half. + * + * The rule this module exists to keep: **a write produces a real file or it fails.** Every + * `write` below serialises through a format library and lands bytes on disk at the path the + * program asked for, and the result carries the byte count read back from that file. None of + * them returns a stringified summary, and none of them reports success for a file it did not + * write. Where a capability is genuinely absent the implementation THROWS and names what is + * missing, exactly as `unavailablePage` does — see `unavailableDocuments`. + * + * Honest limits, stated here because they are properties of the formats and not bugs: + * + * - **`pdf.read` text is extracted from content streams, not rendered.** A page whose text is + * drawn with a composite (`Type0`) font carries glyph identifiers rather than characters, so + * that page's runs are SKIPPED and the page is returned with `textComplete: false` and a + * reason naming the font. A scanned page has no text operators at all and is reported the + * same way — there is no OCR in the engine process. Returning glyph soup as if it were text + * would be the fake-success this module refuses. + * - **Simple-font text is decoded as Latin-1**, which equals WinAnsi for every codepoint the + * standard fonts place below 0x80 and differs only in the 0x80–0x9F band. + * - OOXML reads (`docx`, `pptx`) take the text of the document part. They do not reconstruct + * styling, and `docx.read` does not descend into headers, footers or footnotes. + */ +import * as path from "path" +import * as fs from "node:fs/promises" +import { inflateSync } from "node:zlib" +import { Effect, Schema } from "effect" +import * as zip from "@zip.js/zip.js" +import { Tool as SandboxTool, toolError } from "@redrob-code/codemode" +import { DomainUnavailableError } from "./domain-error" + +/** + * A document operation that reached the format layer and failed there: a missing file, a file + * that is not the container it claims to be, a library rejection. Distinct from + * `DomainUnavailableError`, which says the capability is not present at all. + */ +export class DocumentFailureError extends Schema.TaggedErrorClass()( + "CodeModeDocumentFailure", + { object: Schema.String, operation: Schema.String, reason: Schema.String }, +) { + override get message() { + return `\`${this.object}.${this.operation}\` failed: ${this.reason}` + } +} + +/** Every document call fails with one of these two, and with nothing else. */ +export type DocumentError = DocumentFailureError | DomainUnavailableError + +const fail = (object: string, operation: string, reason: string) => + Effect.fail(new DocumentFailureError({ object, operation, reason })) + +const attempt = (object: string, operation: string, run: () => Promise | A) => + Effect.tryPromise({ + try: async () => await run(), + catch: (cause) => + new DocumentFailureError({ + object, + operation, + reason: cause instanceof Error ? cause.message : String(cause), + }), + }) + +/* ------------------------------------------------------------------------------------------ */ +/* The host: where a path resolves to and who is allowed to be asked about it */ +/* ------------------------------------------------------------------------------------------ */ + +/** + * What the four objects need from the process they run in. Injected rather than imported so a + * test drives real files in a temp directory and the preview path can supply nothing at all. + * + * `guard` is the permission hook: the engine wires it to the same external-directory check the + * `write` tool uses, so a code-mode program cannot write a document outside the instance + * directory without the user being asked. + */ +export interface DocumentHost { + /** Directory a relative path is resolved against. */ + readonly directory: string + /** Called with the resolved absolute path before any write. */ + readonly guard?: (filepath: string) => Effect.Effect +} + +const resolveRead = (host: DocumentHost, object: string, operation: string, filepath: string) => + Effect.gen(function* () { + if (filepath.trim().length === 0) return yield* fail(object, operation, "an empty path is not a file") + return path.isAbsolute(filepath) ? filepath : path.join(host.directory, filepath) + }) + +const resolveWrite = (host: DocumentHost, object: string, operation: string, filepath: string) => + Effect.gen(function* () { + const absolute = yield* resolveRead(host, object, operation, filepath) + if (host.guard) yield* host.guard(absolute) + return absolute + }) + +const readBytes = (object: string, operation: string, absolute: string) => + Effect.gen(function* () { + const stat = yield* Effect.tryPromise({ + try: () => fs.stat(absolute), + catch: () => new DocumentFailureError({ object, operation, reason: `no file at ${absolute}` }), + }) + if (!stat.isFile()) return yield* fail(object, operation, `${absolute} is not a file`) + const buffer = yield* attempt(object, operation, () => fs.readFile(absolute)) + return new Uint8Array(buffer) + }) + +/** + * Writes the bytes, then STATS THE FILE and returns the size the filesystem reports. The + * returned byte count is therefore a reading of the artefact, not of the buffer we hoped to + * write — which is the difference between a verified write and a claimed one. + */ +const writeBytes = (object: string, operation: string, absolute: string, bytes: Uint8Array) => + Effect.gen(function* () { + yield* attempt(object, operation, () => fs.mkdir(path.dirname(absolute), { recursive: true })) + yield* attempt(object, operation, () => fs.writeFile(absolute, bytes)) + const stat = yield* attempt(object, operation, () => fs.stat(absolute)) + if (stat.size === 0) return yield* fail(object, operation, `wrote ${absolute} but it is empty`) + return { path: absolute, bytes: stat.size } + }) + +/* ------------------------------------------------------------------------------------------ */ +/* OOXML: one zip reader for docx and pptx */ +/* ------------------------------------------------------------------------------------------ */ + +// Web workers would outlive the test process and keep it alive; the archives here are small +// enough that inline inflation costs nothing. +zip.configure({ useWebWorkers: false }) + +/** Reads the named parts out of an OOXML package. Returns only the parts that exist. */ +const readParts = (object: string, operation: string, bytes: Uint8Array, wanted: (name: string) => boolean) => + attempt(object, operation, async () => { + const reader = new zip.ZipReader(new zip.Uint8ArrayReader(bytes)) + try { + const entries = await reader.getEntries() + if (entries.length === 0) throw new Error("not an OOXML package: the archive has no entries") + const parts: Array<{ name: string; xml: string }> = [] + for (const entry of entries) { + if (entry.directory || !wanted(entry.filename) || !entry.getData) continue + parts.push({ name: entry.filename, xml: await entry.getData(new zip.TextWriter()) }) + } + return parts + } finally { + await reader.close() + } + }) + +const XML_ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" } + +/** Decodes the XML entities an OOXML text node can carry, including numeric references. */ +const decodeEntities = (text: string) => + text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body: string) => { + if (body.startsWith("#x") || body.startsWith("#X")) { + const code = Number.parseInt(body.slice(2), 16) + return Number.isFinite(code) ? String.fromCodePoint(code) : whole + } + if (body.startsWith("#")) { + const code = Number.parseInt(body.slice(1), 10) + return Number.isFinite(code) ? String.fromCodePoint(code) : whole + } + return XML_ENTITIES[body] ?? whole + }) + +/** The text of one OOXML run container, in document order, with tabs and breaks honoured. */ +const runText = (fragment: string, textTag: string) => { + let out = "" + const pattern = new RegExp(`<${textTag}(?:\\s[^>]*)?>([\\s\\S]*?)|<(?:w|a):(tab|br)\\s*/>`, "g") + for (const match of fragment.matchAll(pattern)) { + if (match[1] !== undefined) out += decodeEntities(match[1]) + else if (match[2] === "tab") out += "\t" + else out += "\n" + } + return out +} + +/* ------------------------------------------------------------------------------------------ */ +/* The four interfaces */ +/* ------------------------------------------------------------------------------------------ */ + +/** One paragraph a `docx.write` lays down. `heading` 1–6 renders as that heading level. */ +export interface DocxParagraph { + readonly text: string + readonly heading?: number + readonly bold?: boolean +} + +export interface DocxResult { + readonly path: string + readonly bytes: number +} + +/** Word documents. */ +export interface Docx { + /** Paragraph text of the document body, in document order. */ + readonly read: (input: { + readonly path: string + }) => Effect.Effect<{ readonly paragraphs: ReadonlyArray; readonly text: string }, DocumentError> + /** Writes a real `.docx` package and returns the path and the size on disk. */ + readonly write: (input: { + readonly path: string + readonly title?: string + readonly paragraphs: ReadonlyArray + }) => Effect.Effect +} + +/** A spreadsheet cell as it crosses the sandbox boundary. */ +export type CellValue = string | number | boolean | null + +export interface XlsxSheet { + readonly name: string + readonly rows: ReadonlyArray> +} + +/** Excel workbooks. */ +export interface Xlsx { + /** Every sheet, or one named sheet, as rows of plain JSON cell values. */ + readonly read: (input: { + readonly path: string + readonly sheet?: string + }) => Effect.Effect<{ readonly sheets: ReadonlyArray }, DocumentError> + /** Writes a real `.xlsx` workbook and returns the path and the size on disk. */ + readonly write: (input: { + readonly path: string + readonly sheets: ReadonlyArray + }) => Effect.Effect +} + +export interface PptxSlideRead { + readonly index: number + readonly text: ReadonlyArray +} + +export interface PptxSlideWrite { + readonly title?: string + readonly bullets?: ReadonlyArray + readonly notes?: string +} + +/** PowerPoint decks. */ +export interface Pptx { + /** The text of every slide, slides in deck order. */ + readonly read: (input: { + readonly path: string + }) => Effect.Effect<{ readonly slides: ReadonlyArray }, DocumentError> + /** Writes a real `.pptx` package and returns the path and the size on disk. */ + readonly write: (input: { + readonly path: string + readonly slides: ReadonlyArray + }) => Effect.Effect +} + +/** + * One page of a `pdf.read`. `textComplete` is the honest field: false means some or all of the + * page's text could not be recovered as characters, and `reason` says why. + */ +export interface PdfPageRead { + readonly index: number + readonly text: string + readonly textComplete: boolean + readonly reason?: string +} + +/** PDF documents. */ +export interface Pdf { + /** Page count, document metadata, and per-page text with its completeness stated. */ + readonly read: (input: { readonly path: string }) => Effect.Effect< + { + readonly pageCount: number + readonly title?: string + readonly author?: string + readonly pages: ReadonlyArray + }, + DocumentError + > + /** Writes a real `.pdf` and returns the path and the size on disk. */ + readonly write: (input: { + readonly path: string + readonly title?: string + readonly fontSize?: number + readonly pages: ReadonlyArray<{ readonly lines: ReadonlyArray }> + }) => Effect.Effect +} + +/** The four objects as one bundle, so the globals and the implementations cannot drift. */ +export interface Documents { + readonly docx: Docx + readonly xlsx: Xlsx + readonly pptx: Pptx + readonly pdf: Pdf +} + +/* ------------------------------------------------------------------------------------------ */ +/* docx */ +/* ------------------------------------------------------------------------------------------ */ + +const makeDocx = (host: DocumentHost): Docx => ({ + read: ({ path: filepath }) => + Effect.gen(function* () { + const absolute = yield* resolveRead(host, "docx", "read", filepath) + const bytes = yield* readBytes("docx", "read", absolute) + const parts = yield* readParts("docx", "read", bytes, (name) => name === "word/document.xml") + const body = parts[0] + if (!body) + return yield* fail("docx", "read", `${absolute} has no word/document.xml, so it is not a Word document`) + const paragraphs: string[] = [] + for (const match of body.xml.matchAll(/]*)?>([\s\S]*?)<\/w:p>/g)) { + paragraphs.push(runText(match[1] ?? "", "w:t")) + } + return { paragraphs, text: paragraphs.join("\n") } + }), + write: ({ path: filepath, title, paragraphs }) => + Effect.gen(function* () { + const absolute = yield* resolveWrite(host, "docx", "write", filepath) + if (paragraphs.length === 0 && title === undefined) + return yield* fail("docx", "write", "nothing to write: give at least a title or one paragraph") + const bytes = yield* attempt("docx", "write", async () => { + const { Document, HeadingLevel, Packer, Paragraph, TextRun } = await import("docx") + const levels = [ + HeadingLevel.HEADING_1, + HeadingLevel.HEADING_2, + HeadingLevel.HEADING_3, + HeadingLevel.HEADING_4, + HeadingLevel.HEADING_5, + HeadingLevel.HEADING_6, + ] + const children = [ + ...(title === undefined ? [] : [new Paragraph({ text: title, heading: HeadingLevel.TITLE })]), + ...paragraphs.map((paragraph) => { + const level = paragraph.heading === undefined ? undefined : levels[Math.min(6, Math.max(1, paragraph.heading)) - 1] + return new Paragraph({ + children: [new TextRun({ text: paragraph.text, bold: paragraph.bold === true })], + ...(level ? { heading: level } : {}), + }) + }), + ] + const document = new Document({ sections: [{ children }] }) + return new Uint8Array(await Packer.toBuffer(document)) + }) + return yield* writeBytes("docx", "write", absolute, bytes) + }), +}) + +/* ------------------------------------------------------------------------------------------ */ +/* xlsx */ +/* ------------------------------------------------------------------------------------------ */ + +/** Projects one ExcelJS cell value onto the JSON shape the sandbox boundary allows. */ +const cellValue = (value: unknown): CellValue => { + if (value === null || value === undefined) return null + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value + if (value instanceof Date) return value.toISOString() + const record = value as Record + // A formula cell carries its cached result; a cell with no cached result is honestly null + // rather than the formula text, which is not the cell's value. + if ("result" in record) return cellValue(record["result"]) + if ("text" in record && typeof record["text"] === "string") return record["text"] + if ("richText" in record && Array.isArray(record["richText"])) + return (record["richText"] as Array<{ text?: string }>).map((run) => run.text ?? "").join("") + if ("error" in record) return String(record["error"]) + if ("hyperlink" in record && typeof record["text"] === "string") return record["text"] + return null +} + +const makeXlsx = (host: DocumentHost): Xlsx => ({ + read: ({ path: filepath, sheet }) => + Effect.gen(function* () { + const absolute = yield* resolveRead(host, "xlsx", "read", filepath) + const bytes = yield* readBytes("xlsx", "read", absolute) + const sheets = yield* attempt("xlsx", "read", async () => { + const { default: ExcelJS } = await import("exceljs") + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(bytes as unknown as ArrayBuffer) + const out: XlsxSheet[] = [] + workbook.eachSheet((worksheet) => { + if (sheet !== undefined && worksheet.name !== sheet) return + const rows: CellValue[][] = [] + worksheet.eachRow({ includeEmpty: true }, (row) => { + // `row.values` is 1-indexed with a hole at 0; drop it rather than emitting a null + // column nobody asked for. + const values = Array.isArray(row.values) ? row.values.slice(1) : [] + rows.push(values.map(cellValue)) + }) + out.push({ name: worksheet.name, rows }) + }) + return out + }) + if (sheet !== undefined && sheets.length === 0) + return yield* fail("xlsx", "read", `${absolute} has no sheet named ${sheet}`) + return { sheets } + }), + write: ({ path: filepath, sheets }) => + Effect.gen(function* () { + const absolute = yield* resolveWrite(host, "xlsx", "write", filepath) + if (sheets.length === 0) return yield* fail("xlsx", "write", "a workbook needs at least one sheet") + const bytes = yield* attempt("xlsx", "write", async () => { + const { default: ExcelJS } = await import("exceljs") + const workbook = new ExcelJS.Workbook() + for (const sheet of sheets) { + const worksheet = workbook.addWorksheet(sheet.name) + for (const row of sheet.rows) worksheet.addRow([...row]) + } + const written = await workbook.xlsx.writeBuffer() + return new Uint8Array(written as ArrayBuffer) + }) + return yield* writeBytes("xlsx", "write", absolute, bytes) + }), +}) + +/* ------------------------------------------------------------------------------------------ */ +/* pptx */ +/* ------------------------------------------------------------------------------------------ */ + +const SLIDE_PART = /^ppt\/slides\/slide(\d+)\.xml$/ + +const makePptx = (host: DocumentHost): Pptx => ({ + read: ({ path: filepath }) => + Effect.gen(function* () { + const absolute = yield* resolveRead(host, "pptx", "read", filepath) + const bytes = yield* readBytes("pptx", "read", absolute) + const parts = yield* readParts("pptx", "read", bytes, (name) => SLIDE_PART.test(name)) + if (parts.length === 0) + return yield* fail("pptx", "read", `${absolute} has no ppt/slides parts, so it is not a PowerPoint deck`) + // Deck order is numeric, not lexical: slide10 comes after slide9, which a string sort + // gets wrong for any deck past nine slides. + const ordered = parts + .map((part) => ({ part, number: Number.parseInt(SLIDE_PART.exec(part.name)![1]!, 10) })) + .sort((a, b) => a.number - b.number) + return { + slides: ordered.map(({ part }, index) => { + const text: string[] = [] + for (const match of part.xml.matchAll(/]*)?>([\s\S]*?)<\/a:p>/g)) { + const line = runText(match[1] ?? "", "a:t") + if (line.length > 0) text.push(line) + } + return { index, text } + }), + } + }), + write: ({ path: filepath, slides }) => + Effect.gen(function* () { + const absolute = yield* resolveWrite(host, "pptx", "write", filepath) + if (slides.length === 0) return yield* fail("pptx", "write", "a deck needs at least one slide") + const bytes = yield* attempt("pptx", "write", async () => { + const { default: PptxGenJS } = await import("pptxgenjs") + const deck = new PptxGenJS() + for (const input of slides) { + const slide = deck.addSlide() + if (input.title !== undefined) + slide.addText(input.title, { x: 0.5, y: 0.4, w: 9, h: 0.8, fontSize: 28, bold: true }) + const bullets = input.bullets ?? [] + if (bullets.length > 0) + slide.addText( + bullets.map((text) => ({ text, options: { bullet: true, fontSize: 16, breakLine: true } })), + { x: 0.7, y: 1.5, w: 8.6, h: 4.5 }, + ) + if (input.notes !== undefined) slide.addNotes(input.notes) + } + const written = await deck.write({ outputType: "nodebuffer" }) + return new Uint8Array(written as ArrayBuffer) + }) + return yield* writeBytes("pptx", "write", absolute, bytes) + }), +}) + +/* ------------------------------------------------------------------------------------------ */ +/* pdf */ +/* ------------------------------------------------------------------------------------------ */ + +/** A PDF literal or hex string, read from a content stream as raw byte codes. */ +const readPdfString = (source: string, start: number): { value: string; next: number } | undefined => { + if (source[start] === "(") { + let depth = 1 + let index = start + 1 + let out = "" + while (index < source.length) { + const char = source[index]! + if (char === "\\") { + const escaped = source[index + 1] + index += 2 + switch (escaped) { + case "n": + out += "\n" + break + case "r": + out += "\r" + break + case "t": + out += "\t" + break + case "b": + case "f": + break + case "\n": + break + default: + if (escaped !== undefined && escaped >= "0" && escaped <= "7") { + let octal = escaped + while (octal.length < 3 && source[index] !== undefined && source[index]! >= "0" && source[index]! <= "7") { + octal += source[index]! + index += 1 + } + out += String.fromCharCode(Number.parseInt(octal, 8)) + } else if (escaped !== undefined) out += escaped + } + continue + } + if (char === "(") depth += 1 + if (char === ")") { + depth -= 1 + if (depth === 0) return { value: out, next: index + 1 } + } + out += char + index += 1 + } + return undefined + } + if (source[start] === "<" && source[start + 1] !== "<") { + const end = source.indexOf(">", start + 1) + if (end < 0) return undefined + const hex = source.slice(start + 1, end).replace(/[^0-9a-fA-F]/g, "") + let out = "" + for (let index = 0; index + 1 < hex.length; index += 2) + out += String.fromCharCode(Number.parseInt(hex.slice(index, index + 2), 16)) + return { value: out, next: end + 1 } + } + return undefined +} + +type Operand = { readonly kind: "string"; readonly value: string } | { readonly kind: "name"; readonly value: string } | { readonly kind: "other" } + +/** + * Walks a decoded content stream and returns the text it shows, plus the names of any fonts + * whose runs were skipped because their codes are not characters. + * + * The scan is an operand stack cleared at every operator, which is what a PDF content stream + * is: postfix operands followed by an operator. `Tf` sets the current font, and a run drawn in + * a font this function cannot decode is dropped and recorded rather than guessed at. + */ +export const extractPdfText = ( + content: string, + decodable: (font: string) => boolean, +): { text: string; skipped: ReadonlyArray } => { + const lines: string[] = [] + let current = "" + let font: string | undefined + const skipped = new Set() + let operands: Operand[] = [] + let index = 0 + + const breakLine = () => { + lines.push(current) + current = "" + } + const show = (value: string) => { + if (font !== undefined && !decodable(font)) { + skipped.add(font) + return + } + current += value + } + + while (index < content.length) { + const char = content[index]! + if (char === "(" || (char === "<" && content[index + 1] !== "<")) { + const read = readPdfString(content, index) + if (!read) break + operands.push({ kind: "string", value: read.value }) + index = read.next + continue + } + if (char === "<" || char === ">") { + index += content[index + 1] === char ? 2 : 1 + continue + } + if (char === "/") { + const match = /^\/([^\s/[\]<>(){}]*)/.exec(content.slice(index))! + operands.push({ kind: "name", value: match[1] ?? "" }) + index += match[0].length + continue + } + if (char === "[" || char === "]") { + index += 1 + continue + } + if (/\s/.test(char)) { + index += 1 + continue + } + const token = /^[^\s/[\]<>(){}]+/.exec(content.slice(index))?.[0] + if (!token) { + index += 1 + continue + } + index += token.length + if (/^[-+.\d]/.test(token)) { + operands.push({ kind: "other" }) + continue + } + switch (token) { + case "Tf": { + const name = operands.findLast((operand) => operand.kind === "name") + if (name && name.kind === "name") font = name.value + break + } + case "Tj": + case "TJ": { + for (const operand of operands) if (operand.kind === "string") show(operand.value) + break + } + case "'": + case '"': { + breakLine() + for (const operand of operands) if (operand.kind === "string") show(operand.value) + break + } + case "Td": + case "TD": + case "T*": + case "TL": + case "Tm": + case "ET": + if (current.length > 0) breakLine() + break + default: + break + } + operands = [] + } + if (current.length > 0) lines.push(current) + return { text: lines.join("\n"), skipped: [...skipped] } +} + +const makePdf = (host: DocumentHost): Pdf => ({ + read: ({ path: filepath }) => + Effect.gen(function* () { + const absolute = yield* resolveRead(host, "pdf", "read", filepath) + const bytes = yield* readBytes("pdf", "read", absolute) + return yield* attempt("pdf", "read", async () => { + const { PDFArray, PDFDict, PDFDocument, PDFName, PDFRawStream } = await import("pdf-lib") + const document = await PDFDocument.load(bytes, { updateMetadata: false }) + const pages: PdfPageRead[] = [] + document.getPages().forEach((page, index) => { + // A composite font's string bytes are glyph ids in the font's own space, so they are + // not text and are not treated as text. Everything else is a simple font whose codes + // are character codes. + const composite = new Set() + const resources = page.node.get(PDFName.of("Resources")) + const fonts = + resources instanceof PDFDict ? document.context.lookupMaybe(resources.get(PDFName.of("Font")), PDFDict) : undefined + if (fonts) + for (const [name, ref] of fonts.entries()) { + const font = document.context.lookupMaybe(ref, PDFDict) + const subtype = font?.get(PDFName.of("Subtype")) + if (subtype !== undefined && String(subtype) === "/Type0") composite.add(name.asString().slice(1)) + } + + const contents = page.node.get(PDFName.of("Contents")) + const refs = + contents instanceof PDFArray + ? contents.asArray() + : contents === undefined + ? [] + : [contents] + let decoded = "" + for (const ref of refs) { + const looked = document.context.lookup(ref) + if (!(looked instanceof PDFRawStream)) continue + const stream = looked + const raw = Buffer.from(stream.getContents()) + const filter = stream.dict.get(PDFName.of("Filter")) + const flate = filter !== undefined && String(filter).includes("FlateDecode") + decoded += flate ? inflateSync(raw).toString("latin1") : raw.toString("latin1") + } + if (decoded.length === 0) { + pages.push({ + index, + text: "", + textComplete: false, + reason: + "the page has no readable content stream: it is an image-only or scanned page, and the engine process has no OCR", + }) + return + } + const { text, skipped } = extractPdfText(decoded, (font) => !composite.has(font)) + if (skipped.length > 0) { + pages.push({ + index, + text, + textComplete: false, + reason: `text drawn with composite (Type0) font${skipped.length === 1 ? "" : "s"} ${skipped.join(", ")} was skipped: those strings are glyph identifiers, not characters, and no ToUnicode mapping is applied`, + }) + return + } + if (text.length === 0) { + pages.push({ + index, + text: "", + textComplete: false, + reason: "the content stream shows no text: the page draws only graphics", + }) + return + } + pages.push({ index, text, textComplete: true }) + }) + const title = document.getTitle() + const author = document.getAuthor() + return { + pageCount: document.getPageCount(), + ...(title ? { title } : {}), + ...(author ? { author } : {}), + pages, + } + }) + }), + write: ({ path: filepath, title, fontSize, pages }) => + Effect.gen(function* () { + const absolute = yield* resolveWrite(host, "pdf", "write", filepath) + if (pages.length === 0) return yield* fail("pdf", "write", "a PDF needs at least one page") + const size = fontSize ?? 12 + if (!Number.isFinite(size) || size <= 0) return yield* fail("pdf", "write", `fontSize ${fontSize} is not a size`) + const bytes = yield* attempt("pdf", "write", async () => { + const { PDFDocument, StandardFonts } = await import("pdf-lib") + const document = await PDFDocument.create() + if (title !== undefined) document.setTitle(title) + const font = await document.embedFont(StandardFonts.Helvetica) + const [width, height] = [595.28, 841.89] // A4 at 72dpi, the size a reader expects + const margin = 56 + const leading = size * 1.45 + for (const input of pages) { + let page = document.addPage([width, height]) + let cursor = height - margin + for (const line of input.lines) { + // A page that runs out of room continues onto a new one rather than silently + // dropping the rest of the text. + if (cursor < margin) { + page = document.addPage([width, height]) + cursor = height - margin + } + // WinAnsi is what the standard 14 fonts encode; a character outside it would make + // pdf-lib throw, so it is replaced rather than failing the whole write. + const drawable = [...line].map((char) => (char.codePointAt(0)! > 0xff ? "?" : char)).join("") + page.drawText(drawable, { x: margin, y: cursor, size, font }) + cursor -= leading + } + } + return new Uint8Array(await document.save()) + }) + return yield* writeBytes("pdf", "write", absolute, bytes) + }), +}) + +/* ------------------------------------------------------------------------------------------ */ +/* The two implementations: the real one, and the one that refuses */ +/* ------------------------------------------------------------------------------------------ */ + +/** The working implementation, bound to a directory and an optional permission guard. */ +export const documents = (host: DocumentHost): Documents => ({ + docx: makeDocx(host), + xlsx: makeXlsx(host), + pptx: makePptx(host), + pdf: makePdf(host), +}) + +/** + * The refusing implementation, for the catalog preview — which describes the globals without + * being a live execution and so has no directory to resolve a path against. Same discipline as + * `unavailablePage`: every method fails with the reason named, and none of them returns a + * plausible value or a fake success. + */ +export const unavailableDocuments = (missing = "this is a catalog preview, not a live execution"): Documents => { + const refuse = (object: string) => + Effect.fail(new DomainUnavailableError({ object, missing })) as Effect.Effect + return { + docx: { read: () => refuse("docx"), write: () => refuse("docx") }, + xlsx: { read: () => refuse("xlsx"), write: () => refuse("xlsx") }, + pptx: { read: () => refuse("pptx"), write: () => refuse("pptx") }, + pdf: { read: () => refuse("pdf"), write: () => refuse("pdf") }, + } +} + +/* ------------------------------------------------------------------------------------------ */ +/* Code Mode namespaces */ +/* ------------------------------------------------------------------------------------------ */ + +const PathInput = Schema.String.annotate({ + description: "Path to the file. Relative paths resolve against the instance directory.", +}) + +const WriteResult = Schema.Struct({ + path: Schema.String, + bytes: Schema.Number, +}).annotate({ description: "The absolute path written and the size the filesystem reports for it." }) + +const CellSchema = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Null]) + +const SheetSchema = Schema.Struct({ + name: Schema.String.annotate({ description: "Sheet name." }), + rows: Schema.Array(Schema.Array(CellSchema)).annotate({ description: "Rows of cell values, top-left first." }), +}) + +/** + * Lifts a document call into a Code Mode tool. Both error kinds carry a sentence, so an + * unavailable object and a format failure read as precise refusals in the program rather than + * as an opaque execution failure. + */ +const lift = + (call: (input: I) => Effect.Effect) => + (input: I) => + call(input).pipe(Effect.mapError((error) => toolError(error.message, error))) + +export const docxTools = (docx: Docx) => ({ + read: SandboxTool.make({ + description: "Paragraph text of a Word document, in document order.", + input: Schema.Struct({ path: PathInput }), + output: Schema.Struct({ paragraphs: Schema.Array(Schema.String), text: Schema.String }), + run: lift((input: { path: string }) => docx.read(input)), + }), + write: SandboxTool.make({ + description: "Write a real .docx file. Returns the path written and its size on disk.", + input: Schema.Struct({ + path: PathInput, + title: Schema.optionalKey(Schema.String.annotate({ description: "Document title paragraph." })), + paragraphs: Schema.Array( + Schema.Struct({ + text: Schema.String, + heading: Schema.optionalKey(Schema.Number.annotate({ description: "Heading level 1-6." })), + bold: Schema.optionalKey(Schema.Boolean), + }), + ), + }), + output: WriteResult, + run: lift( + (input: { path: string; title?: string; paragraphs: ReadonlyArray }) => docx.write(input), + ), + }), +}) + +export const xlsxTools = (xlsx: Xlsx) => ({ + read: SandboxTool.make({ + description: "Rows of an Excel workbook as plain cell values, every sheet or one named sheet.", + input: Schema.Struct({ + path: PathInput, + sheet: Schema.optionalKey(Schema.String.annotate({ description: "Read only this sheet." })), + }), + output: Schema.Struct({ sheets: Schema.Array(SheetSchema) }), + run: lift((input: { path: string; sheet?: string }) => xlsx.read(input)), + }), + write: SandboxTool.make({ + description: "Write a real .xlsx workbook. Returns the path written and its size on disk.", + input: Schema.Struct({ path: PathInput, sheets: Schema.Array(SheetSchema) }), + output: WriteResult, + run: lift((input: { path: string; sheets: ReadonlyArray }) => xlsx.write(input)), + }), +}) + +export const pptxTools = (pptx: Pptx) => ({ + read: SandboxTool.make({ + description: "The text of every slide in a PowerPoint deck, in deck order.", + input: Schema.Struct({ path: PathInput }), + output: Schema.Struct({ + slides: Schema.Array(Schema.Struct({ index: Schema.Number, text: Schema.Array(Schema.String) })), + }), + run: lift((input: { path: string }) => pptx.read(input)), + }), + write: SandboxTool.make({ + description: "Write a real .pptx deck. Returns the path written and its size on disk.", + input: Schema.Struct({ + path: PathInput, + slides: Schema.Array( + Schema.Struct({ + title: Schema.optionalKey(Schema.String), + bullets: Schema.optionalKey(Schema.Array(Schema.String)), + notes: Schema.optionalKey(Schema.String.annotate({ description: "Speaker notes." })), + }), + ), + }), + output: WriteResult, + run: lift((input: { path: string; slides: ReadonlyArray }) => pptx.write(input)), + }), +}) + +export const pdfTools = (pdf: Pdf) => ({ + read: SandboxTool.make({ + description: + "Page count, metadata and per-page text of a PDF. `textComplete` is false with a reason where text could not be recovered as characters.", + input: Schema.Struct({ path: PathInput }), + output: Schema.Struct({ + pageCount: Schema.Number, + title: Schema.optionalKey(Schema.String), + author: Schema.optionalKey(Schema.String), + pages: Schema.Array( + Schema.Struct({ + index: Schema.Number, + text: Schema.String, + textComplete: Schema.Boolean, + reason: Schema.optionalKey(Schema.String), + }), + ), + }), + run: lift((input: { path: string }) => pdf.read(input)), + }), + write: SandboxTool.make({ + description: "Write a real .pdf. Lines are laid out in Helvetica and overflow onto new pages.", + input: Schema.Struct({ + path: PathInput, + title: Schema.optionalKey(Schema.String), + fontSize: Schema.optionalKey(Schema.Number.annotate({ description: "Point size, default 12." })), + pages: Schema.Array(Schema.Struct({ lines: Schema.Array(Schema.String) })), + }), + output: WriteResult, + run: lift( + (input: { + path: string + title?: string + fontSize?: number + pages: ReadonlyArray<{ lines: ReadonlyArray }> + }) => pdf.write(input), + ), + }), +}) + +/** The global names K-3 adds, in the order they are declared. */ +export const DOCUMENT_GLOBALS = ["docx", "pdf", "pptx", "xlsx"] as const + +/** The four namespaces keyed by the global each is bound to. */ +export const documentTools = (input: Documents) => ({ + docx: docxTools(input.docx), + pdf: pdfTools(input.pdf), + pptx: pptxTools(input.pptx), + xlsx: xlsxTools(input.xlsx), +}) diff --git a/packages/redrob/src/tool/domain-error.ts b/packages/redrob/src/tool/domain-error.ts new file mode 100644 index 0000000000..43a2fd3274 --- /dev/null +++ b/packages/redrob/src/tool/domain-error.ts @@ -0,0 +1,23 @@ +/** + * The one error both K-1's domain objects and K-3's document objects raise when the capability + * itself is absent. + * + * It lives in its own module ON PURPOSE. `domain.ts` binds K-3's globals into `DOMAIN_GLOBALS` + * at module scope, and `document.ts` needs this class — so importing it from `domain.ts` would + * make the two modules a cycle whose evaluation order decides whether `DOCUMENT_GLOBALS` is + * defined when `domain.ts` reads it. A leaf module has no such order to get wrong. + */ +import { Schema } from "effect" + +/** + * A domain object the running engine cannot reach. Carries the missing capability by name + * so the model, the logs, and a person reading a transcript all see the same reason. + */ +export class DomainUnavailableError extends Schema.TaggedErrorClass()( + "CodeModeDomainUnavailable", + { object: Schema.String, missing: Schema.String }, +) { + override get message() { + return `The \`${this.object}\` domain object is not available in this session: ${this.missing}` + } +} diff --git a/packages/redrob/src/tool/domain.ts b/packages/redrob/src/tool/domain.ts new file mode 100644 index 0000000000..f698a90d51 --- /dev/null +++ b/packages/redrob/src/tool/domain.ts @@ -0,0 +1,312 @@ +/** + * K-1: the typed domain objects a skill document is written against. + * + * A skill is cheap only because these objects exist. Each one is a real TypeScript + * interface with a single implementation, exported from this module and injected into the + * Code Mode interpreter scope under its own name, so a skill body reads + * + * const body = await page.text() + * await channel.send({ text: body.slice(0, 200) }) + * + * rather than describing a tool call. Code Mode itself stays host-neutral: it is handed a + * namespace of tools plus the list of names to bind as globals, and never learns what + * `page` or `channel` mean (see packages/codemode/AGENTS.md). + * + * `page` is resolved through the browser channel (PA-10): each call becomes a pending + * request published on the event stream the client already subscribes to, and the attached + * browser settles it. `bridgedPage` is that implementation. Where no channel exists — the + * catalog preview, a harness with no browser — the implementation is `unavailablePage`, + * which THROWS a message naming what is missing rather than returning a plausible value, so + * a skill written against the interface fails loudly instead of silently reading an empty + * string. + */ +import { BrowserRequestV1 } from "@redrob-code/core/browser-request" +import { SessionV1 } from "@redrob-code/core/v1/session" +import { Effect, Schema } from "effect" +import { Tool as SandboxTool, toolError } from "@redrob-code/codemode" +import { PartID, type MessageID, type SessionID } from "../session/schema" +import type { Session } from "@/session/session" +import { DOCUMENT_GLOBALS, documentTools, unavailableDocuments, type Documents } from "./document" +import { DomainUnavailableError } from "./domain-error" + +/** One node returned by a page query. Kept to what a skill can act on without a handle. */ +export interface PageNode { + readonly selector: string + readonly text: string + readonly attributes: Readonly> +} + +/** + * A domain object the running engine cannot reach. Carries the missing capability by name + * so the model, the logs, and a person reading a transcript all see the same reason. + * + * Defined in `./domain-error` and re-exported here, so `document.ts` can raise it without + * this module and that one forming an evaluation cycle. Importers keep importing it from + * here. + */ +export { DomainUnavailableError } from "./domain-error" + +/** + * The browser page the agent is acting in. + * + * Every method can fail with `DomainUnavailableError`, because whether a page exists is a + * property of the session, not of the call. + */ +export interface Page { + /** The page's current URL. */ + readonly url: () => Effect.Effect + /** The page's visible text. */ + readonly text: () => Effect.Effect + /** Nodes matching a CSS selector, in document order. */ + readonly query: (input: { + readonly selector: string + readonly limit?: number + }) => Effect.Effect, DomainUnavailableError> + /** Clicks the first node matching a selector. */ + readonly click: (input: { readonly selector: string }) => Effect.Effect + /** Types text into the first node matching a selector, optionally submitting afterwards. */ + readonly type: (input: { + readonly selector: string + readonly text: string + readonly submit?: boolean + }) => Effect.Effect + /** Navigates the page and returns the URL actually landed on. */ + readonly navigate: (input: { readonly url: string }) => Effect.Effect +} + +/** The channel this session belongs to: where a message or a result is delivered. */ +export interface Channel { + /** Identifier of the conversation this session posts into. */ + readonly id: () => Effect.Effect + /** Posts a message visible to the person in the conversation. Returns the part id written. */ + readonly send: (input: { readonly text: string }) => Effect.Effect +} + +/** + * The `Page` for a caller with no browser channel: every method refuses by name. + * + * Still the right implementation for the catalog preview and for any harness that binds no + * browser, so the interface, the tool schemas and the generated instructions all exist and + * are exercised whether or not a browser is attached. `missing` names the capability, not + * the symptom. + */ +export const unavailablePage = ( + missing = "no browser client is attached to this engine; the page object needs the browser extension connected to this engine's event stream", +): Page => { + const refuse = () => + Effect.fail(new DomainUnavailableError({ object: "page", missing })) as Effect.Effect + return { + url: () => refuse(), + text: () => refuse(), + query: () => refuse>(), + click: () => refuse(), + type: () => refuse(), + navigate: () => refuse(), + } +} + +/** + * The `Page` that actually works: every call becomes a pending browser request the + * attached client settles. + * + * This is PA-10. The engine still controls nothing itself — it publishes the action on the + * event stream it already serves and waits for an answer, so no socket is opened and + * nothing local can reach the browser through the engine. + * + * Every failure mode is mapped to `DomainUnavailableError` carrying the service's own + * sentence, because the `Page` interface promises exactly that error and a skill reads the + * sentence. The three causes stay distinguishable in the text: nobody answered, the browser + * refused, or the client answered with a shape the action cannot produce. + */ +export const bridgedPage = (input: { + readonly browser: BrowserRequestV1.Interface + readonly sessionID: SessionID +}): Page => { + const unavailable = (missing: string) => new DomainUnavailableError({ object: "page", missing }) + + const ask = (command: BrowserRequestV1.Command) => + input.browser + .ask({ sessionID: input.sessionID, command }) + .pipe(Effect.mapError((error) => unavailable(error.message))) + + /** + * Reads the value a settled request carried, insisting on the shape the action promised. + * + * The insistence is the point. A client answering `page.query` with a string must not + * become an empty node list: "nothing matched the selector" and "the client answered + * wrongly" are different claims and only one of them is about the page. + */ + const expect = + (expected: BrowserRequestV1.Value["type"], read: (value: BrowserRequestV1.Value) => A | undefined) => + (command: BrowserRequestV1.Command) => + ask(command).pipe( + Effect.flatMap((value) => { + const taken = read(value) + if (taken === undefined) { + return Effect.fail( + unavailable( + new BrowserRequestV1.MalformedError({ action: command.action, expected, received: value.type }) + .message, + ), + ) + } + return Effect.succeed(taken) + }), + ) + + const expectString = expect("string", (value) => (value.type === "string" ? value.value : undefined)) + const expectNodes = expect>("nodes", (value) => + value.type === "nodes" ? value.value : undefined, + ) + // `void` reads as `null` rather than `undefined`, since `undefined` is this helper's own + // "wrong shape" signal and would turn every successful click into a malformed answer. + const expectVoid = expect("void", (value) => (value.type === "void" ? null : undefined)) + + return { + url: () => expectString({ action: "page.url" }), + text: () => expectString({ action: "page.text" }), + query: (query) => expectNodes({ action: "page.query", ...query }), + click: (click) => expectVoid({ action: "page.click", ...click }).pipe(Effect.asVoid), + type: (type) => expectVoid({ action: "page.type", ...type }).pipe(Effect.asVoid), + navigate: (navigate) => expectString({ action: "page.navigate", ...navigate }), + } +} + +/** + * The single `Channel` implementation: posts into the session the program is running in, + * by appending a text part to the assistant message that owns the execution. Every surface + * the session is attached to renders that part, so this is the channel in the sense the + * skill means it, without the engine having to know which surface is attached. + */ +export const sessionChannel = (input: { + readonly sessions: Session.Interface + readonly sessionID: SessionID + readonly messageID: MessageID +}): Channel => ({ + id: () => Effect.succeed(input.sessionID), + send: ({ text }) => + Effect.gen(function* () { + const part = yield* input.sessions.updatePart({ + id: PartID.ascending(), + messageID: input.messageID, + sessionID: input.sessionID, + type: "text", + text, + } satisfies SessionV1.TextPart) + return part.id + }), +}) + +/** + * A `Channel` that refuses. Used where the catalog is described rather than executed, so + * the preview instructions list the same globals a real execution binds without a preview + * being able to post anything into a conversation. + */ +export const unavailableChannel = (missing = "this is a catalog preview, not a live execution"): Channel => { + const refuse = () => + Effect.fail(new DomainUnavailableError({ object: "channel", missing })) as Effect.Effect + return { id: () => refuse(), send: () => refuse() } +} + +const Empty = Schema.Struct({}) + +/** Narrow JSON projection of a page node, so results cross the sandbox data boundary. */ +const PageNodeSchema = Schema.Struct({ + selector: Schema.String, + text: Schema.String, + attributes: Schema.Record(Schema.String, Schema.String), +}) + +/** + * Lifts a domain call into a Code Mode tool. A `DomainUnavailableError` becomes a + * model-safe tool failure carrying the same sentence, so an unavailable object reads as a + * precise refusal in the program rather than as an opaque execution failure. + */ +const lift = + (call: (input: I) => Effect.Effect) => + (input: I) => + call(input).pipe(Effect.mapError((error) => toolError(error.message, error))) + +/** `page` as a Code Mode namespace. The tool names are the interface's method names. */ +export const pageTools = (page: Page) => ({ + url: SandboxTool.make({ + description: "Current URL of the page the agent is acting in.", + input: Empty, + output: Schema.String, + run: lift(() => page.url()), + }), + text: SandboxTool.make({ + description: "Visible text of the current page.", + input: Empty, + output: Schema.String, + run: lift(() => page.text()), + }), + query: SandboxTool.make({ + description: "Nodes matching a CSS selector, in document order.", + input: Schema.Struct({ + selector: Schema.String.annotate({ description: "CSS selector." }), + limit: Schema.optionalKey(Schema.Number.annotate({ description: "Maximum nodes to return." })), + }), + output: Schema.Array(PageNodeSchema), + run: lift((input: { selector: string; limit?: number }) => page.query(input)), + }), + click: SandboxTool.make({ + description: "Click the first node matching a CSS selector.", + input: Schema.Struct({ selector: Schema.String.annotate({ description: "CSS selector." }) }), + output: Schema.Null, + run: lift((input: { selector: string }) => page.click(input).pipe(Effect.as(null))), + }), + type: SandboxTool.make({ + description: "Type text into the first node matching a CSS selector.", + input: Schema.Struct({ + selector: Schema.String.annotate({ description: "CSS selector." }), + text: Schema.String.annotate({ description: "Text to type." }), + submit: Schema.optionalKey(Schema.Boolean.annotate({ description: "Submit the field afterwards." })), + }), + output: Schema.Null, + run: lift((input: { selector: string; text: string; submit?: boolean }) => page.type(input).pipe(Effect.as(null))), + }), + navigate: SandboxTool.make({ + description: "Navigate the page and return the URL landed on.", + input: Schema.Struct({ url: Schema.String.annotate({ description: "Absolute URL to open." }) }), + output: Schema.String, + run: lift((input: { url: string }) => page.navigate(input)), + }), +}) + +/** `channel` as a Code Mode namespace. */ +export const channelTools = (channel: Channel) => ({ + id: SandboxTool.make({ + description: "Identifier of the conversation this session posts into.", + input: Empty, + output: Schema.String, + run: lift(() => channel.id()), + }), + send: SandboxTool.make({ + description: "Post a message into the conversation this session belongs to.", + input: Schema.Struct({ text: Schema.String.annotate({ description: "Message text." }) }), + output: Schema.String, + run: lift((input: { text: string }) => channel.send(input)), + }), +}) + +/** The names bound as bare globals in the interpreter scope, in the order they are declared. */ +export const DOMAIN_GLOBALS = ["channel", "page", ...DOCUMENT_GLOBALS] as const + +/** + * The domain namespaces for one execution, keyed by the global name each is bound to. + * Returned as one object so the tool tree and `DOMAIN_GLOBALS` cannot drift apart. + * + * `documents` defaults to the refusing implementation: a caller that has no directory to + * resolve a path against — the catalog preview — still advertises the same globals a live + * execution binds, and every call on them fails by name instead of pretending. + */ +export const domainTools = (input: { + readonly page: Page + readonly channel: Channel + readonly documents?: Documents +}) => ({ + channel: channelTools(input.channel), + page: pageTools(input.page), + ...documentTools(input.documents ?? unavailableDocuments()), +}) diff --git a/packages/redrob/src/tool/image_generate.ts b/packages/redrob/src/tool/image_generate.ts new file mode 100644 index 0000000000..12ff427f09 --- /dev/null +++ b/packages/redrob/src/tool/image_generate.ts @@ -0,0 +1,218 @@ +import { Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import * as path from "path" +import * as Tool from "./tool" +import DESCRIPTION from "./image_generate.txt" +import { Auth } from "@/auth" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { FileSystem } from "@redrob-code/core/filesystem" +import { Watcher } from "@redrob-code/core/filesystem/watcher" +import { FSUtil } from "@redrob-code/core/fs-util" +import { CONSOLE_URL } from "@redrob-code/core/plugin/provider/redrob-constants" +import { assertExternalDirectoryEffect } from "./external-directory" + +/** + * Image generation through the Redrob gateway, on the Redrob credential this engine already holds. + * + * A built-in rather than a plugin tool because the credential is the engine's: it lives in the auth + * store (or `REDROB_API_KEY`), the desktop app never reads it, and no plugin API hands it out. Calling + * the gateway from here keeps the key where it is and bills the image to the same account as the chat. + * + * The gateway relays an image model's pictures in OpenRouter's shape, `choices[0].message.images`, + * when asked with `modalities: ["image", "text"]`. The picture is written under `artifacts/` and also + * returned as an attachment, which is what puts it inline in the conversation and in front of the model. + */ + +/** A good, mid-priced default. Any image-output model the gateway serves can be named instead. */ +export const DEFAULT_IMAGE_MODEL = "google/gemini-2.5-flash-image" + +/** + * The gateway, read per call. `REDROB_CONSOLE_URL` is the same developer seam `CONSOLE_URL` honours + * (never set in a shipped build); reading it here rather than only at module load is what lets a test + * point one call at a stand-in without depending on which module loaded first. + */ +export function gatewayUrl(): string { + return (process.env["REDROB_CONSOLE_URL"] ?? CONSOLE_URL).replace(/\/+$/, "") +} + +const TIMEOUT_MS = 180_000 +const MAX_IMAGE_BYTES = 20 * 1024 * 1024 + +const EXTENSIONS: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/gif": "gif", +} + +export const Parameters = Schema.Struct({ + prompt: Schema.String.annotate({ description: "A full visual description of the image to generate" }), + filename: Schema.optional(Schema.String).annotate({ + description: "Optional file name without an extension. Derived from the prompt when omitted.", + }), + model: Schema.optional(Schema.String).annotate({ + description: `Optional image model id served by the Redrob gateway. Defaults to ${DEFAULT_IMAGE_MODEL}.`, + }), +}) + +const GatewayResponse = Schema.Struct({ + choices: Schema.Array( + Schema.Struct({ + message: Schema.Struct({ + content: Schema.optional(Schema.NullOr(Schema.String)), + images: Schema.optional(Schema.Array(Schema.Struct({ image_url: Schema.Struct({ url: Schema.String }) }))), + }), + }), + ), + redrob: Schema.optional(Schema.Struct({ costUsd: Schema.optional(Schema.Number) })), +}) + +const decodeResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(GatewayResponse)) + +/** The Redrob key: the engine's stored credential first, then the environment, as the provider resolves it. */ +export function redrobKey(stored: Auth.Info | undefined): string | undefined { + if (stored?.type === "api" && stored.key.trim()) return stored.key.trim() + return process.env.REDROB_API_KEY?.trim() || undefined +} + +/** A file-name stem from free text: lower case, ASCII letters and digits, at most 48 characters. */ +export function slug(value: string): string { + const stem = value + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) + .replace(/-+$/, "") + return stem || "image" +} + +/** A data URL's type and bytes, or undefined when it is not an inline image of a type we write. */ +export function decodeImageDataUrl(url: string): { mime: string; bytes: Uint8Array } | undefined { + const match = /^data:(image\/[a-z+.-]+);base64,([A-Za-z0-9+/=\s]+)$/i.exec(url) + if (!match) return undefined + const mime = match[1].toLowerCase() + if (!EXTENSIONS[mime]) return undefined + const bytes = Uint8Array.from(Buffer.from(match[2], "base64")) + if (bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_BYTES) return undefined + return { mime, bytes } +} + +export const ImageGenerateTool = Tool.define( + "image_generate", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const auth = yield* Auth.Service + const fs = yield* FSUtil.Service + const events = yield* EventV2Bridge.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const prompt = params.prompt.trim() + if (!prompt) throw new Error("Describe the image to generate.") + const key = redrobKey(yield* auth.get("redrob").pipe(Effect.orElseSucceed(() => undefined))) + if (!key) throw new Error("Connect Redrob to generate images: no Redrob API key is available.") + const model = params.model?.trim() || DEFAULT_IMAGE_MODEL + + const instance = yield* InstanceState.context + const directory = path.join(instance.directory, "artifacts") + const stem = slug(params.filename?.trim() || prompt) + + /* + * The path is chosen before anything is spent, so the request is refused for free where a file + * cannot be written. Asked as `edit`, like `write`, because writing a file is what this does: + * plan mode, which denies edits, denies this too. + */ + const extension = "png" + let filepath = path.join(directory, `${stem}.${extension}`) + for (let n = 2; yield* fs.existsSafe(filepath); n += 1) { + filepath = path.join(directory, `${stem}-${n}.${extension}`) + } + yield* assertExternalDirectoryEffect(ctx, filepath) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(instance.worktree, filepath)], + always: ["*"], + metadata: { filepath, prompt, model }, + }) + + const request = yield* HttpClientRequest.post(`${gatewayUrl()}/chat/completions`).pipe( + HttpClientRequest.bearerToken(key), + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJson({ + model, + messages: [{ role: "user", content: prompt }], + modalities: ["image", "text"], + }), + ) + const response = yield* http.execute(request).pipe( + Effect.timeoutOrElse({ + duration: TIMEOUT_MS, + orElse: () => Effect.die(new Error("Image generation timed out")), + }), + ) + const body = yield* response.text + if (response.status < 200 || response.status >= 300) { + throw new Error(`The Redrob gateway answered ${response.status}: ${gatewayMessage(body)}`) + } + const payload = yield* decodeResponse(body) + const message = payload.choices[0]?.message + const image = (message?.images ?? []).map((item) => decodeImageDataUrl(item.image_url.url)).find(Boolean) + if (!image) { + const said = message?.content?.trim() + throw new Error(`${model} returned no image${said ? `. It said: ${said.slice(0, 500)}` : "."}`) + } + + /* A model that answered in another format keeps the chosen name with the right extension. */ + if (EXTENSIONS[image.mime] !== extension) { + const base = filepath.slice(0, -extension.length - 1) + filepath = `${base}.${EXTENSIONS[image.mime]}` + for (let n = 2; yield* fs.existsSafe(filepath); n += 1) { + filepath = `${base}-${n}.${EXTENSIONS[image.mime]}` + } + } + yield* fs.writeWithDirs(filepath, image.bytes) + yield* events.publish(FileSystem.Event.Edited, { file: filepath }) + yield* events.publish(Watcher.Event.Updated, { file: filepath, event: "add" }) + + const relative = path.relative(instance.worktree, filepath) + const costUsd = payload.redrob?.costUsd + const caption = message?.content?.trim() + return { + title: relative, + output: [`Generated ${relative} with ${model}.`, caption ? `The model said: ${caption}` : undefined] + .filter(Boolean) + .join("\n"), + metadata: { filepath, model, ...(costUsd === undefined ? {} : { costUsd }) }, + attachments: [ + { + type: "file" as const, + mime: image.mime, + filename: path.basename(filepath), + url: `data:${image.mime};base64,${Buffer.from(image.bytes).toString("base64")}`, + }, + ], + } + }).pipe(Effect.orDie), + } + }), +) + +/** The gateway's error message, from OpenAI's envelope or Nest's, without echoing a whole body. */ +export function gatewayMessage(body: string): string { + try { + const parsed: unknown = JSON.parse(body) + if (parsed && typeof parsed === "object") { + const error = "error" in parsed ? parsed.error : undefined + if (error && typeof error === "object" && "message" in error && typeof error.message === "string") { + return error.message + } + if ("message" in parsed && typeof parsed.message === "string") return parsed.message + } + } catch {} + return body.slice(0, 200) || "no details" +} diff --git a/packages/redrob/src/tool/image_generate.txt b/packages/redrob/src/tool/image_generate.txt new file mode 100644 index 0000000000..bf065a045d --- /dev/null +++ b/packages/redrob/src/tool/image_generate.txt @@ -0,0 +1,9 @@ +- Generates an image from a text description and saves it in the project's `artifacts/` folder +- Use this when the user asks for a picture, illustration, logo, diagram-like artwork, mockup, or any other image +- The image is shown to the user in the conversation and returned to you, so you can see what was made and refine it with another call +- Billed to the user's Redrob account per image, so make one image per request unless the user asks for variations + +Usage notes: + - Write the prompt as a full visual description: subject, setting, style, composition, colors, and any text that must appear in the image + - `filename` is optional and has no extension; a name is derived from the prompt when it is omitted, and an existing file is never overwritten + - Leave `model` unset unless the user names an image model diff --git a/packages/redrob/src/tool/registry.ts b/packages/redrob/src/tool/registry.ts index 51b1f2eb12..8b1c35417b 100644 --- a/packages/redrob/src/tool/registry.ts +++ b/packages/redrob/src/tool/registry.ts @@ -26,6 +26,9 @@ import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { WebSearchTool } from "./websearch" +import { ImageGenerateTool, redrobKey } from "./image_generate" +import { SpeechGenerateTool } from "./speech_generate" +import { Auth } from "@/auth" import { LspTool } from "./lsp" import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" @@ -52,15 +55,13 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@redrob-code/core/provider" import { ModelV2 } from "@redrob-code/core/model" import { MCP } from "@/mcp" +import { BrowserRequestV1 } from "@redrob-code/core/browser-request" import { PermissionV1 } from "@redrob-code/core/v1/permission" import { McpCatalog } from "@/mcp/catalog" export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { return ( - providerID === ProviderV2.ID.redrob || - providerID === ProviderV2.ID.make("redrob-go") || - flags.exa || - flags.parallel + providerID === ProviderV2.ID.redrob || providerID === ProviderV2.ID.make("redrob-go") || flags.exa || flags.parallel ) } @@ -107,6 +108,9 @@ const layer = Layer.effect( const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool + const imageGenerate = yield* ImageGenerateTool + const speechGenerate = yield* SpeechGenerateTool + const auth = yield* Auth.Service const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool @@ -218,6 +222,8 @@ const layer = Layer.effect( fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), + image: Tool.init(imageGenerate), + speech: Tool.init(speechGenerate), skill: Tool.init(skilltool), patch: Tool.init(patchtool), question: Tool.init(question), @@ -241,6 +247,8 @@ const layer = Layer.effect( tool.fetch, tool.todo, tool.search, + tool.image, + tool.speech, tool.skill, tool.patch, ...(tool.execute ? [tool.execute] : []), @@ -289,7 +297,14 @@ const layer = Layer.effect( }) const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { + /* + * Image and speech generation are offered only where they can run: they bill the Redrob account, so without a + * Redrob credential each would be a tool that fails on every call. Read per request because the + * credential can be connected or removed while the engine runs. + */ + const canGenerateMedia = Boolean(redrobKey(yield* auth.get("redrob").pipe(Effect.orElseSucceed(() => undefined)))) const filtered = (yield* all()).filter((tool) => { + if (tool.id === ImageGenerateTool.id || tool.id === SpeechGenerateTool.id) return canGenerateMedia if (tool.id === WebSearchTool.id) { return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) } @@ -437,6 +452,7 @@ export const node = LayerNode.make({ Session.node, BackgroundJob.node, Provider.node, + Auth.node, LSP.node, Instruction.node, FSUtil.node, @@ -447,6 +463,9 @@ export const node = LayerNode.make({ Truncate.node, RuntimeFlags.node, MCP.node, + // PA-10: the code-mode tool resolves `page` through this service, so the registry that + // builds that tool depends on it. + BrowserRequestV1.node, Database.node, Ripgrep.node, ], diff --git a/packages/redrob/src/tool/speech_generate.ts b/packages/redrob/src/tool/speech_generate.ts new file mode 100644 index 0000000000..e358522802 --- /dev/null +++ b/packages/redrob/src/tool/speech_generate.ts @@ -0,0 +1,221 @@ +import { Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import * as path from "path" +import * as Tool from "./tool" +import DESCRIPTION from "./speech_generate.txt" +import { Auth } from "@/auth" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { FileSystem } from "@redrob-code/core/filesystem" +import { Watcher } from "@redrob-code/core/filesystem/watcher" +import { FSUtil } from "@redrob-code/core/fs-util" +import { assertExternalDirectoryEffect } from "./external-directory" +import { gatewayMessage, gatewayUrl, redrobKey, slug } from "./image_generate" + +/** + * Speech through the Redrob gateway's `POST /v1/audio/speech`, on the engine's own Redrob credential, + * for the reason image_generate gives: the key is the engine's and stays here. + * + * Two callers share `prepareSpeech` and `requestSpeech`: the `speech_generate` tool, which saves an + * mp3, and the engine's `POST /redrob/speech` route, which hands the audio to Redrob Cowork to play + * a reply aloud without the app ever holding the key. + * + * The tool saves its audio and does NOT return it as an attachment. An attachment goes back to the + * model with the next turn, and audio input would steer `auto` to an audio-input model and bill the + * clip as input on every turn after, to tell the model nothing it does not already know: it wrote + * the words. + */ + +/** Multilingual, so a Korean script is spoken as Korean. Any speech model the gateway serves can be named. */ +export const DEFAULT_SPEECH_MODEL = "elevenlabs/eleven-multilingual-v2" +export const DEFAULT_SPEECH_VOICE = "sarah" + +/** + * For speech that is played as it arrives, a reply read aloud or a voice conversation's answer, where + * the wait before the first word matters more than the last bit of quality. Same voice, so a saved file + * and a reply read aloud sound like the same speaker. Multilingual, so Korean is spoken as Korean. + */ +export const PLAYBACK_SPEECH_MODEL = "elevenlabs/eleven-flash-v2.5" + +export type SpeechDefaults = { model: string; voice: string } +const FILE_DEFAULTS: SpeechDefaults = { model: DEFAULT_SPEECH_MODEL, voice: DEFAULT_SPEECH_VOICE } +export const PLAYBACK_DEFAULTS: SpeechDefaults = { model: PLAYBACK_SPEECH_MODEL, voice: DEFAULT_SPEECH_VOICE } + +/** The gateway's limit for one request, checked here so an over-long request costs nothing. */ +export const MAX_SPEECH_CHARACTERS = 4_096 + +const TIMEOUT_MS = 180_000 +const MAX_AUDIO_BYTES = 50 * 1024 * 1024 + +/** + * Why speech could not be made. `invalid` and `no_key` are found before anything is sent and cost + * nothing; `upstream` and `timeout` are the gateway's. + */ +export class SpeechError extends Schema.TaggedErrorClass()("SpeechError", { + reason: Schema.Literals(["invalid", "no_key", "upstream", "timeout"]), + message: Schema.String, + status: Schema.optional(Schema.Number), +}) {} + +export type PreparedSpeech = { text: string; model: string; voice?: string; characters: number } + +/** Characters as the gateway bills them: code points, so the limit here agrees with its limit there. */ +function characters(text: string): number { + let count = 0 + for (const _ of text) count += 1 + return count +} + +/** The request as it will be sent, or why it would be refused, with nothing sent either way. */ +export const prepareSpeech = Effect.fn("Speech.prepare")(function* ( + input: { text: string; model?: string; voice?: string }, + defaults: SpeechDefaults = FILE_DEFAULTS, +) { + const text = input.text.trim() + if (!text) return yield* new SpeechError({ reason: "invalid", message: "Give the words to speak." }) + const length = characters(text) + if (length > MAX_SPEECH_CHARACTERS) { + return yield* new SpeechError({ + reason: "invalid", + message: `The text is ${length} characters; speak at most ${MAX_SPEECH_CHARACTERS} per call and save longer material in parts.`, + }) + } + const model = input.model?.trim() || defaults.model + /* Voices belong to a model, so the default voice only goes with the default model. */ + const voice = input.voice?.trim() || (model === defaults.model ? defaults.voice : undefined) + const prepared: PreparedSpeech = { text, model, ...(voice ? { voice } : {}), characters: length } + return prepared +}) + +/** The engine's Redrob key, or a `no_key` failure, which is found before anything is sent. */ +export const speechKey = Effect.fn("Speech.key")(function* (auth: Auth.Interface) { + const key = redrobKey(yield* auth.get("redrob").pipe(Effect.orElseSucceed(() => undefined))) + if (!key) { + return yield* new SpeechError({ + reason: "no_key", + message: "Connect Redrob to generate speech: no Redrob API key is available.", + }) + } + return key +}) + +/** One call to the gateway. The audio is checked for being audio before anyone keeps or plays it. */ +export const requestSpeech = Effect.fn("Speech.request")(function* ( + http: HttpClient.HttpClient, + key: string, + speech: PreparedSpeech, +) { + const request = yield* HttpClientRequest.post(`${gatewayUrl()}/audio/speech`).pipe( + HttpClientRequest.bearerToken(key), + HttpClientRequest.bodyJson({ + model: speech.model, + input: speech.text, + response_format: "mp3", + ...(speech.voice ? { voice: speech.voice } : {}), + }), + Effect.orDie, + ) + const response = yield* http.execute(request).pipe( + Effect.mapError(() => new SpeechError({ reason: "upstream", message: "The Redrob gateway could not be reached." })), + Effect.timeoutOrElse({ + duration: TIMEOUT_MS, + orElse: () => Effect.fail(new SpeechError({ reason: "timeout", message: "Speech generation timed out." })), + }), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")) + return yield* new SpeechError({ + reason: "upstream", + status: response.status, + message: `The Redrob gateway answered ${response.status}: ${gatewayMessage(body)}`, + }) + } + const contentType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase() ?? "" + if (!contentType.startsWith("audio/")) { + return yield* new SpeechError({ + reason: "upstream", + message: `The Redrob gateway answered with ${contentType || "no content type"}, not audio.`, + }) + } + const buffer = yield* response.arrayBuffer.pipe( + Effect.mapError(() => new SpeechError({ reason: "upstream", message: "The Redrob gateway's audio was cut off." })), + ) + const audio = new Uint8Array(buffer) + if (audio.byteLength === 0) { + return yield* new SpeechError({ reason: "upstream", message: "The Redrob gateway returned no audio." }) + } + if (audio.byteLength > MAX_AUDIO_BYTES) { + return yield* new SpeechError({ reason: "upstream", message: "The audio is over 50 MB." }) + } + const cost = Number(response.headers["x-redrob-cost-usd"]) + return { audio, contentType, ...(Number.isFinite(cost) ? { costUsd: cost } : {}) } +}) + +export const Parameters = Schema.Struct({ + text: Schema.String.annotate({ description: `The words to speak, at most ${MAX_SPEECH_CHARACTERS} characters` }), + voice: Schema.optional(Schema.String).annotate({ + description: `Optional voice of the chosen model. Defaults to ${DEFAULT_SPEECH_VOICE} with the default model.`, + }), + model: Schema.optional(Schema.String).annotate({ + description: `Optional speech model id served by the Redrob gateway. Defaults to ${DEFAULT_SPEECH_MODEL}.`, + }), + filename: Schema.optional(Schema.String).annotate({ + description: "Optional file name without an extension. Derived from the text when omitted.", + }), +}) + +export const SpeechGenerateTool = Tool.define( + "speech_generate", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const auth = yield* Auth.Service + const fs = yield* FSUtil.Service + const events = yield* EventV2Bridge.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const speech = yield* prepareSpeech(params) + const key = yield* speechKey(auth) + + const instance = yield* InstanceState.context + const directory = path.join(instance.directory, "artifacts") + const stem = slug(params.filename?.trim() || speech.text) + let filepath = path.join(directory, `${stem}.mp3`) + for (let n = 2; yield* fs.existsSafe(filepath); n += 1) { + filepath = path.join(directory, `${stem}-${n}.mp3`) + } + yield* assertExternalDirectoryEffect(ctx, filepath) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(instance.worktree, filepath)], + always: ["*"], + metadata: { filepath, model: speech.model, voice: speech.voice }, + }) + + const spoken = yield* requestSpeech(http, key, speech) + yield* fs.writeWithDirs(filepath, spoken.audio) + yield* events.publish(FileSystem.Event.Edited, { file: filepath }) + yield* events.publish(Watcher.Event.Updated, { file: filepath, event: "add" }) + + const relative = path.relative(instance.worktree, filepath) + return { + title: relative, + output: `Saved ${relative}: ${speech.characters} characters spoken with ${speech.model}${speech.voice ? ` (${speech.voice})` : ""}.`, + metadata: { + filepath, + model: speech.model, + ...(speech.voice ? { voice: speech.voice } : {}), + characters: speech.characters, + ...(spoken.costUsd === undefined ? {} : { costUsd: spoken.costUsd }), + }, + } + }).pipe( + Effect.catchTag("SpeechError", (error) => Effect.die(new Error(error.message))), + Effect.orDie, + ), + } + }), +) diff --git a/packages/redrob/src/tool/speech_generate.txt b/packages/redrob/src/tool/speech_generate.txt new file mode 100644 index 0000000000..6efe5784c8 --- /dev/null +++ b/packages/redrob/src/tool/speech_generate.txt @@ -0,0 +1,10 @@ +- Turns text into spoken audio and saves it as an MP3 in the project's `artifacts/` folder +- Use this when the user asks for a voice-over, narration, a spoken summary, an audio version of a document, or anything else they want to listen to +- The audio is not played back to you; you get the file's path. The user can play it in the conversation +- Billed to the user's Redrob account per character, so speak only what the user asked for + +Usage notes: + - `text` is read aloud as written, up to 4096 characters per call; split longer material into parts and save each as its own file + - Write numbers, abbreviations and names the way they should be spoken + - Leave `model` and `voice` unset unless the user asks for a specific one; the default speaks Korean, English and many other languages + - `filename` is optional and has no extension; a name is derived from the text when it is omitted, and an existing file is never overwritten diff --git a/packages/redrob/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/redrob/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index f81a6e951b..b920c7312f 100644 --- a/packages/redrob/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/redrob/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -18,7 +18,9 @@ Options: --mdns-domain custom domain name for mDNS service (default: redrob.local) [string] [default: "redrob.local"] --cors additional domains to allow for CORS [array] [default: []] - --cwd working directory [string] [default: ""]" + --cwd working directory [string] [default: ""] + --attach use the user's shared engine (start it if needed) instead of running one inside + this process; one daemon serves every app [boolean] [default: false]" `; exports[`redrob CLI help-text snapshots every documented command emits stable help text: redrob mcp --help 1`] = ` @@ -219,7 +221,11 @@ Options: [boolean] [default: false] --mdns-domain custom domain name for mDNS service (default: redrob.local) [string] [default: "redrob.local"] - --cors additional domains to allow for CORS [array] [default: []]" + --cors additional domains to allow for CORS [array] [default: []] + --register run as the user's shared daemon: draw a secret, write ~/.redrob/run/server.json + for other Redrob apps to find, exit when idle [boolean] [default: false] + --idle-exit with --register: minutes with no attached app before exiting (0 = stay up) + [number] [default: 10]" `; exports[`redrob CLI help-text snapshots every documented command emits stable help text: redrob models --help 1`] = ` diff --git a/packages/redrob/test/config/config.test.ts b/packages/redrob/test/config/config.test.ts index 3c3af2692f..3d51a50fed 100644 --- a/packages/redrob/test/config/config.test.ts +++ b/packages/redrob/test/config/config.test.ts @@ -36,6 +36,7 @@ import { Global } from "@redrob-code/core/global" import { ProjectV2 } from "@redrob-code/core/project" import { Filesystem } from "@/util/filesystem" import { ConfigPlugin } from "@/config/plugin" +import { ConfigPaths } from "@/config/paths" import { ConfigPluginV1 } from "@redrob-code/core/v1/config/plugin" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" @@ -1433,6 +1434,43 @@ it.instance("local .redrob config can override MCP from project config", () => }), ) +it.instance("loads app-managed directory config from the state dir, below the project's own files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const dir = ConfigPaths.directoryConfigDir(test.directory) + expect(dir.startsWith(path.join(Global.Path.state, "directories"))).toBe(true) + expect(ConfigPaths.directoryKey(test.directory)).toBe(ConfigPaths.directoryKey(test.directory + path.sep)) + expect(ConfigPaths.directoryKey(test.directory)).not.toBe(ConfigPaths.directoryKey(path.join(test.directory, "x"))) + + yield* FSUtil.use.ensureDir(dir) + yield* writeConfigEffect( + dir, + { + $schema: "https://code.redrob.ai/config.json", + snapshot: false, + mcp: { + docs: { type: "remote", url: "https://docs.example.com/mcp", enabled: false }, + app: { type: "remote", url: "https://app.example.com/mcp", enabled: true }, + }, + }, + "redrob.json", + ) + yield* writeConfigEffect(test.directory, { + $schema: "https://code.redrob.ai/config.json", + mcp: { docs: { type: "remote", url: "https://docs.example.com/mcp", enabled: true } }, + }) + + try { + const config = yield* Config.use.get() + expect(config.snapshot).toBe(false) + expect(config.mcp?.app?.enabled).toBe(true) + expect(config.mcp?.docs?.enabled).toBe(true) + } finally { + yield* Effect.promise(() => fs.rm(dir, { recursive: true, force: true })) + } + }), +) + const remoteProjectOverride = wellKnown({ config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, diff --git a/packages/redrob/test/daemon.test.ts b/packages/redrob/test/daemon.test.ts new file mode 100644 index 0000000000..7325acea38 --- /dev/null +++ b/packages/redrob/test/daemon.test.ts @@ -0,0 +1,199 @@ +/** + * The shared daemon's record and the attached-apps registry. The process-level behaviour (start, + * attach over HTTP, idle exit) was driven by hand against a dev daemon; these pin the pure parts + * so a change to the record shape or the idle accounting fails here first. + */ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Daemon } from "@/daemon/connect" +import * as Registry from "@/daemon/registry" +import * as Clients from "@redrob-code/server/clients" + +const dirs: string[] = [] +async function runDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "redrob-run-")) + dirs.push(dir) + return { REDROB_RUN_DIR: dir } as NodeJS.ProcessEnv +} +afterEach(async () => { + for (const dir of dirs.splice(0)) await fs.rm(dir, { recursive: true, force: true }) +}) + +const record = (over: Partial = {}): Registry.Record => ({ + id: "id-1", + version: "0.0.0", + url: "http://127.0.0.1:4096", + port: 4096, + pid: process.pid, + secret: "s3cret", + ...over, +}) + +describe("daemon registry", () => { + test("writes the record with mode 0600 and reads it back", async () => { + const env = await runDir() + await Registry.write(record(), env) + // POSIX mode bits only: Windows reports 0666 for everything and protects the file by ACL. + if (process.platform !== "win32") { + const stat = await fs.stat(Registry.file(env)) + expect(stat.mode & 0o777).toBe(0o600) + } + expect(await Registry.read(env)).toEqual(record()) + }) + + test("a record that does not parse, or lacks a field, reads as absent", async () => { + const env = await runDir() + await fs.mkdir(Registry.directory(env), { recursive: true }) + await fs.writeFile(Registry.file(env), "{not json") + expect(await Registry.read(env)).toBeUndefined() + await fs.writeFile(Registry.file(env), JSON.stringify({ url: "x", pid: 1 })) + expect(await Registry.read(env)).toBeUndefined() + }) + + test("remove leaves a newer daemon's record alone", async () => { + const env = await runDir() + await Registry.write(record({ id: "newer" }), env) + await Registry.remove("older", env) + expect((await Registry.read(env))?.id).toBe("newer") + await Registry.remove("newer", env) + expect(await Registry.read(env)).toBeUndefined() + }) + + test("alive tells a live pid from a dead one", () => { + expect(Registry.alive(process.pid)).toBe(true) + expect(Registry.alive(2 ** 22 - 1)).toBe(false) + }) + + test("the Basic header carries the daemon's username and secret", () => { + const header = Registry.headers("abc").Authorization + expect(header.startsWith("Basic ")).toBe(true) + expect(Buffer.from(header.slice(6), "base64").toString()).toBe("redrob:abc") + }) + + test("healthy is false for a daemon that does not answer, and true for one that does", async () => { + const no = await Registry.healthy(record({ url: "http://127.0.0.1:9" }), undefined, 300) + expect(no).toBe(false) + const yes = await Registry.healthy(record(), (async () => + Response.json({ healthy: true })) as unknown as typeof fetch) + expect(yes).toBe(true) + const wrong = await Registry.healthy( + record(), + (async () => new Response("", { status: 401 })) as unknown as typeof fetch, + ) + expect(wrong).toBe(false) + }) +}) + +describe("attached apps", () => { + test("idle accounting: idle from start, not idle while attached, idle again when the last leaves", () => { + Clients.reset(1_000) + expect(Clients.idleFor(4_000)).toBe(3_000) + const a = Clients.attach({ app: "cowork" }, 5_000) + const b = Clients.attach({ app: "design", pid: 7 }, 6_000) + expect(Clients.idleFor(7_000)).toBeUndefined() + expect(Clients.list().map((c) => c.app)).toEqual(["cowork", "design"]) + expect(Clients.detach(a.id, 8_000)).toBe(true) + expect(Clients.idleFor(9_000)).toBeUndefined() + expect(Clients.detach(b.id, 10_000)).toBe(true) + expect(Clients.idleFor(12_500)).toBe(2_500) + expect(Clients.detach(b.id)).toBe(false) + }) + + test("requests count as activity: not idle while one is in flight, idle timer restarts after it", () => { + Clients.reset(1_000) + expect(Clients.idleFor(4_000)).toBe(3_000) + Clients.requestStarted(5_000) + expect(Clients.idleFor(600_000)).toBeUndefined() + Clients.requestEnded(20_000) + expect(Clients.idleFor(25_000)).toBe(5_000) + // Two overlapping requests: idle only once both are answered. + Clients.requestStarted(30_000) + Clients.requestStarted(31_000) + Clients.requestEnded(32_000) + expect(Clients.idleFor(40_000)).toBeUndefined() + Clients.requestEnded(33_000) + expect(Clients.idleFor(40_000)).toBe(7_000) + // An attachment still wins over the request clock. + const a = Clients.attach({ app: "office" }, 41_000) + expect(Clients.idleFor(90_000)).toBeUndefined() + Clients.detach(a.id, 50_000) + expect(Clients.idleFor(60_000)).toBe(10_000) + }) + + test("status polls are not activity; everything else is", () => { + expect(Clients.countsAsActivity("/api/health")).toBe(false) + expect(Clients.countsAsActivity("/api/clients")).toBe(false) + expect(Clients.countsAsActivity("/api/clients/abc")).toBe(false) + expect(Clients.countsAsActivity("/v1/chat/completions")).toBe(true) + expect(Clients.countsAsActivity("/provider")).toBe(true) + expect(Clients.countsAsActivity("/api/healthz")).toBe(true) + }) + + test("an app whose heartbeats stop is swept; a heartbeat keeps one alive", () => { + Clients.reset(0) + const live = Clients.attach({ app: "office" }, 0) + const dead = Clients.attach({ app: "crashed" }, 0) + Clients.heartbeat(live.id, Clients.TTL_MS) + expect(Clients.sweep(Clients.TTL_MS + 1)).toBe(1) + expect(Clients.list().map((c) => c.id)).toEqual([live.id]) + expect(Clients.heartbeat(dead.id)).toBeUndefined() + expect(Clients.sweep(2 * Clients.TTL_MS + 2)).toBe(1) + expect(Clients.idleFor(2 * Clients.TTL_MS + 2)).toBe(0) + }) + + test("an app name is trimmed, bounded and never empty", () => { + Clients.reset() + expect(Clients.attach({ app: " " }).app).toBe("unknown") + expect(Clients.attach({ app: " x".repeat(80) }).app.length).toBe(64) + }) +}) + +describe("attach", () => { + test("posts the app, heartbeats, re-attaches after a 404 and deletes on detach", async () => { + const calls: string[] = [] + let ids = 0 + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + const url = String(input) + const method = init?.method ?? "GET" + calls.push(`${method} ${new URL(url).pathname}`) + if (method === "POST" && url.endsWith("/api/clients")) { + expect(JSON.parse(String(init?.body)).app).toBe("cut") + return Response.json({ id: `c${++ids}` }) + } + if (method === "GET") return Response.json({ clients: [], ttlMs: 15_000 }) + if (url.endsWith("/heartbeat")) return new Response("", { status: ids === 1 ? 404 : 200 }) + return new Response("", { status: 204 }) + }) as unknown as typeof fetch + const connection = { url: "http://127.0.0.1:4096/", headers: Registry.headers("s"), record: record() } + const a = await Daemon.attach(connection, "cut", { pid: 7, fetch: fetchImpl }) + expect(a.id).toBe("c1") + await new Promise((r) => setTimeout(r, 5_300)) + // The first heartbeat met a 404 (daemon restarted), so the attachment was re-created. + expect(a.id).toBe("c2") + await a.detach() + await a.detach() + expect(calls).toEqual([ + "POST /api/clients", + "GET /api/clients", + "POST /api/clients/c1/heartbeat", + "POST /api/clients", + "DELETE /api/clients/c2", + ]) + }, 10_000) +}) + +describe("directory config", () => { + test("the SDK computes the same per-directory config dir as the engine", async () => { + const { ConfigPaths } = await import("@/config/paths") + const { Global } = await import("@redrob-code/core/global") + const sdk = await import("@redrob-labs/sdk/daemon") + const dir = path.join(os.tmpdir(), "redrob-dircfg", "Project") + expect(sdk.directoryKey(dir)).toBe(ConfigPaths.directoryKey(dir)) + expect(sdk.directoryKey(dir + path.sep)).toBe(sdk.directoryKey(dir)) + expect(sdk.directoryConfigDir(dir, { XDG_STATE_HOME: path.dirname(Global.Path.state) })).toBe( + ConfigPaths.directoryConfigDir(dir), + ) + }) +}) diff --git a/packages/redrob/test/event-manifest.test.ts b/packages/redrob/test/event-manifest.test.ts index 8c152a0910..743c6a4bcf 100644 --- a/packages/redrob/test/event-manifest.test.ts +++ b/packages/redrob/test/event-manifest.test.ts @@ -9,12 +9,18 @@ describe("public event manifest", () => { expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) - expect(EventManifest.Latest.size).toBe(88) + expect(EventManifest.Latest.size).toBe(91) expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(EventManifest.Latest.has("server.connected")).toBe(true) expect(EventManifest.Latest.has("global.disposed")).toBe(true) + // PA-10's three, named rather than merely counted. The size above is a tripwire for an + // accidental addition, and a bare bump would have satisfied it without saying what + // arrived -- so the thing that moved the count asserts itself here. + expect(EventManifest.Latest.has("browser.request.asked")).toBe(true) + expect(EventManifest.Latest.has("browser.request.answered")).toBe(true) + expect(EventManifest.Latest.has("browser.request.refused")).toBe(true) }) test("contains only the current step settlement versions", () => { diff --git a/packages/redrob/test/installation/launcher-assets.test.ts b/packages/redrob/test/installation/launcher-assets.test.ts new file mode 100644 index 0000000000..d345be5881 --- /dev/null +++ b/packages/redrob/test/installation/launcher-assets.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { $ } from "bun" +import { + MANIFEST, + MANIFEST_NAME, + STEM, + TARGETS, + expectedLauncherAssets, + isLauncherAssetName, + launcherAssetName, + manifestText, + pack, + verifyReleaseDirectory, + writeManifest, +} from "../../script/launcher-assets" + +/** + * Redrob Launcher installs an asset only when its name is `---[-portable].` + * with a token from a fixed list. The build's own names (`redrob-linux-x64.tar.gz`) do not parse, so + * every release before this check shipped nothing the launcher could install -- silently, because + * the release workflow was green. This pins the names the publish job adds and the layout inside. + */ +describe("launcher asset names", () => { + test("every produced name is one the launcher parses", () => { + for (const name of expectedLauncherAssets("0.5.1")) { + expect(isLauncherAssetName(name)).toBe(true) + } + expect(expectedLauncherAssets("0.5.1").sort()).toEqual( + [ + "redrob-code-0.5.1-linux-x86_64.tar.gz", + "redrob-code-0.5.1-linux-aarch64.tar.gz", + "redrob-code-0.5.1-macos-aarch64-portable.zip", + "redrob-code-0.5.1-macos-x86_64-portable.zip", + "redrob-code-0.5.1-windows-x64-portable.zip", + "redrob-code-0.5.1-windows-arm64-portable.zip", + ].sort(), + ) + }) + + test("a prerelease version survives the right-to-left split", () => { + expect(isLauncherAssetName(launcherAssetName("0.6.0-rc.1", "linux-x86_64", "tar.gz"))).toBe(true) + }) + + test("the build's own names and the wrong spellings do not parse", () => { + for (const bad of [ + "redrob-linux-x64.tar.gz", // no version, stem `redrob` + "redrob-darwin-arm64.zip", + "redrob-code-x64-setup.exe", // the launcher does not install an exe + "redrob-code-v0.5.1-linux-x86_64.tar.gz", // leading v + "redrob-code-0.5.1-linux-x64.tar.gz", // x64 is not a linux token + "redrob-code-0.5.1-linux-amd64.tar.gz", + "redrob-code-0.5.1-darwin-arm64-portable.zip", // darwin, arm64 are not macOS tokens + "redrob-code-0.5.1-macos-arm64.zip", // a plain zip is not a package kind + "redrob-code-0.5.1-win-x64-portable.zip", + ]) { + expect(isLauncherAssetName(bad)).toBe(false) + } + }) + + // The publish job runs the packer on ubuntu only, and that is the only place it is meant to run: + // Windows' tar does not take the same symlink and a user-mode symlink needs a privilege the + // runner does not hold. The name tests above still run everywhere. + const unix = test.skipIf(process.platform === "win32") + + unix("packs a launcher tarball with bin/redrob-code and copies the signed zips", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "launcher-assets-")) + try { + const unsigned = path.join(root, "unsigned") + const signed = path.join(root, "signed") + const out = path.join(root, "out") + for (const [dir, target] of Object.entries(TARGETS)) { + if (target.kind === "tar.gz") { + fs.mkdirSync(path.join(unsigned, dir, "bin"), { recursive: true }) + fs.writeFileSync(path.join(unsigned, dir, "bin", "redrob"), `#!/bin/sh\necho ${dir}\n`) + } else { + fs.mkdirSync(signed, { recursive: true }) + fs.writeFileSync(path.join(signed, `${dir}.zip`), `zip:${dir}`) + } + } + + const names = await pack({ version: "0.5.1", unsigned, signed, out }) + expect(names.sort()).toEqual(expectedLauncherAssets("0.5.1").sort()) + expect(fs.readdirSync(out).sort()).toEqual([...expectedLauncherAssets("0.5.1"), MANIFEST_NAME].sort()) + expect(fs.readFileSync(path.join(out, MANIFEST_NAME), "utf8")).toBe(manifestText()) + expect(verifyReleaseDirectory(out, "0.5.1")).toEqual([]) + + // The launcher unpacks into a single top folder and looks for `bin/`. + const listing = await $`tar -tzvf ${path.join(out, "redrob-code-0.5.1-linux-x86_64.tar.gz")}`.text() + expect(listing).toContain(`redrob-code-0.5.1-linux-x86_64/bin/${STEM}`) + expect(listing).toMatch(/redrob-code-0\.5\.1-linux-x86_64\/bin\/redrob -> redrob-code/) + expect(listing).toMatch(/^-rwxr-xr-x .* redrob-code-0\.5\.1-linux-x86_64\/bin\/redrob-code$/m) + + // The signed archive is the asset, unchanged: the notarized bytes are what gets installed. + expect(fs.readFileSync(path.join(out, "redrob-code-0.5.1-macos-aarch64-portable.zip"), "utf8")).toBe( + "zip:redrob-darwin-arm64", + ) + expect(fs.readFileSync(path.join(out, "redrob-code-0.5.1-windows-arm64-portable.zip"), "utf8")).toBe( + "zip:redrob-windows-arm64", + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + /** + * `redrob-launcher.json` is how the launcher learns, without a launcher release, that this release + * reads its shared account file (the launcher's `docs/release-manifest.md`). Exactly `{ "schema": 1, + * "sharedAccount": true }`: a wrong schema is ignored by the launcher and a false flag hides the + * sign-in, both silently, so the bytes are pinned here. + */ + describe("release manifest", () => { + test("is the launcher contract, verbatim", () => { + expect(MANIFEST_NAME).toBe("redrob-launcher.json") + expect(manifestText()).toBe('{ "schema": 1, "sharedAccount": true }\n') + const parsed = JSON.parse(manifestText()) + expect(parsed).toEqual({ schema: 1, sharedAccount: true }) + expect(parsed.schema).toBe(1) + expect(parsed.sharedAccount).toBe(true) + expect(MANIFEST).toEqual({ schema: 1, sharedAccount: true }) + }) + + test("a release directory without it fails the check", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "launcher-manifest-")) + try { + for (const name of expectedLauncherAssets("0.5.1")) fs.writeFileSync(path.join(root, name), name) + expect(verifyReleaseDirectory(root, "0.5.1")).toEqual([`missing ${MANIFEST_NAME}`]) + + writeManifest(root) + expect(verifyReleaseDirectory(root, "0.5.1")).toEqual([]) + + fs.writeFileSync(path.join(root, MANIFEST_NAME), '{ "schema": 2, "sharedAccount": false }\n') + expect(verifyReleaseDirectory(root, "0.5.1")).toEqual([ + `${MANIFEST_NAME} schema is 2, expected 1`, + `${MANIFEST_NAME} sharedAccount is false, expected true`, + ]) + + fs.writeFileSync(path.join(root, MANIFEST_NAME), "not json") + expect(verifyReleaseDirectory(root, "0.5.1")[0]).toMatch(/is not JSON/) + + fs.rmSync(path.join(root, "redrob-code-0.5.1-linux-x86_64.tar.gz")) + writeManifest(root) + expect(verifyReleaseDirectory(root, "0.5.1")).toEqual([ + "missing launcher asset redrob-code-0.5.1-linux-x86_64.tar.gz", + ]) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + }) + + unix("refuses to pack when a platform's build output is missing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "launcher-assets-")) + try { + await expect( + pack({ version: "0.5.1", unsigned: path.join(root, "nope"), signed: root, out: path.join(root, "out") }), + ).rejects.toThrow(/missing build output/) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/redrob/test/lib/llm-server.ts b/packages/redrob/test/lib/llm-server.ts index 245acc7280..6c7f7fbd20 100644 --- a/packages/redrob/test/lib/llm-server.ts +++ b/packages/redrob/test/lib/llm-server.ts @@ -775,5 +775,21 @@ export class TestLLMServer extends Context.Service [...misses]), }) }), - ).pipe(Layer.provide(HttpRouter.layer), Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 }))) + ).pipe( + // Layer.fresh, not a bare HttpRouter.layer. Layer memoization otherwise hands + // this fake the SAME HttpRouter instance the engine's own HttpApi is built on, + // so both register into one router. That was harmless only while no engine + // route shared a path with the fake: once the engine gained + // POST /v1/chat/completions -- the real OpenAI path, which this fake must also + // serve to be a credible provider double -- the second registration threw + // "Method 'POST' already declared for route '/v1/chat/completions'" and took + // down every test that stands up both. + // + // The fake's path is deliberately NOT renamed to dodge the clash: it exists to + // look like a real OpenAI-compatible endpoint, and a fake at a made-up path + // would stop exercising the thing under test. Isolating the router is the fix; + // the collision was a wiring accident, not a naming one. + Layer.provide(Layer.fresh(HttpRouter.layer)), + Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })), + ) } diff --git a/packages/redrob/test/preload.ts b/packages/redrob/test/preload.ts index 7aeb7ad295..741ce813f3 100644 --- a/packages/redrob/test/preload.ts +++ b/packages/redrob/test/preload.ts @@ -35,6 +35,10 @@ process.env["XDG_DATA_HOME"] = path.join(dir, "share") process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") +// Redrob Launcher's shared account.json is the console key's last fallback. XDG_CONFIG_HOME above already +// moves it on Linux; the override moves it on every platform, so a signed-in launcher on a developer's +// machine never satisfies a "no credential" branch here. +process.env["REDROB_SHARED_DIR"] = path.join(dir, "shared") process.env["REDROB_DISABLE_MODELS_FETCH"] = "true" // Feed the provider tests a realistic multi-provider catalog (with paid redrob models) through // the test-only fixture seam instead of the console /models network fetch. diff --git a/packages/redrob/test/provider/provider.test.ts b/packages/redrob/test/provider/provider.test.ts index 4023b7bcea..55f8363426 100644 --- a/packages/redrob/test/provider/provider.test.ts +++ b/packages/redrob/test/provider/provider.test.ts @@ -378,6 +378,16 @@ it.instance( { config: { provider: {} } }, ) +it.instance( + "defaultModel picks redrob/auto when the redrob provider is the one in use", + Effect.gen(function* () { + const model = yield* Provider.use.defaultModel() + expect(String(model.providerID)).toBe("redrob") + expect(String(model.modelID)).toBe("auto") + }), + { config: { enabled_providers: ["redrob"] } }, +) + it.instance( "defaultModel returns a typed error when config excludes every provider", Effect.gen(function* () { @@ -846,6 +856,29 @@ test("provider.sort prioritizes preferred models", () => { expect(sorted[sorted.length - 1].id).not.toContain("sonnet-4") }) +test("provider.sort never puts an image model first", () => { + // The redrob catalog listed gemini-3-pro-image-preview; "gemini-3-pro" in the priority list + // matched it, so it became the default and every chat turn failed. + const models = [ + { id: "gemini-3-pro-image-preview", capabilities: { output: { text: true, image: true } } }, + { id: "gpt-image-1", capabilities: { output: { text: false, image: true } } }, + { id: "gemini-3-pro-preview", capabilities: { output: { text: true, image: false } } }, + { id: "some-chat-model", capabilities: { output: { text: true, image: false } } }, + ] as any[] + const sorted = Provider.sort(models) + expect(sorted[0].id).toBe("gemini-3-pro-preview") + expect(sorted.slice(-2).map((m) => m.id).sort()).toEqual(["gemini-3-pro-image-preview", "gpt-image-1"]) + + // Bare {id} rows (the ACP model picker) fall back to the id. + const bare = Provider.sort([{ id: "gemini-3-pro-image-preview" }, { id: "plain-model" }]) + expect(bare[0].id).toBe("plain-model") +}) + +test("isChatModel does not reject ids that merely contain 'image' inside a word", () => { + expect(Provider.isChatModel({ id: "imagenation-chat" })).toBe(true) + expect(Provider.isChatModel({ id: "flux/image" })).toBe(false) +}) + it.instance( "multiple providers can be configured simultaneously", Effect.gen(function* () { @@ -2140,3 +2173,121 @@ it.effect("redrob loader lists the same models with or without an auth.json cred expect(none).toBe(keyedCount) }).pipe(provideMultiInstance), ) + +/* + Redrob Launcher's shared `account.json` as the LAST fallback for the console key, and how the provider + report says so. A host app reads `GET /provider` and shows "Signed in with Redrob Launcher" from + `source: "launcher"`; it reads `options.apiKey === "public"` as "not connected", so with the shared file + present that placeholder must not be set. Every case points REDROB_SHARED_DIR at a scratch directory. +*/ +const withSharedAccount = (body: unknown) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const saved = process.env["REDROB_SHARED_DIR"] + yield* Effect.acquireRelease( + Effect.sync(() => { + rememberEnv("REDROB_SHARED_DIR") + process.env["REDROB_SHARED_DIR"] = dir + }), + () => + Effect.sync(() => { + if (saved === undefined) delete process.env["REDROB_SHARED_DIR"] + else process.env["REDROB_SHARED_DIR"] = saved + }), + ) + if (body !== undefined) { + yield* Effect.promise(() => Filesystem.write(path.join(dir, "account.json"), JSON.stringify(body))) + } + return dir + }) + +/** + * Pin the engine's own auth.json to `entry` for the test (`undefined` = absent) and put it back after. + * Files in one bun process share Global.Path.data, and the device-connect suite leaves a redrob entry + * there, which would otherwise win over the shared file and turn these into tests of that leak. + */ +const withOwnAuth = (entry: unknown) => + Effect.gen(function* () { + const authPath = path.join(Global.Path.data, "auth.json") + const original = yield* Effect.promise(() => Filesystem.readText(authPath).catch(() => undefined)) + yield* Effect.acquireRelease( + Effect.promise(async () => { + if (entry === undefined) await unlink(authPath).catch(() => undefined) + else await Filesystem.write(authPath, JSON.stringify({ redrob: entry })) + }), + () => + Effect.promise(async () => { + if (original !== undefined) await Filesystem.write(authPath, original) + else await unlink(authPath).catch(() => undefined) + }), + ) + return authPath + }) + +const launcherAccount = { + apiKey: "rk_from_launcher", + apiKeyName: "Redrob Launcher", + accountName: "Acme", + apiBaseUrl: "https://console.redrob.ai/api/backend/v1", +} + +const listIn = (directory: string) => + Provider.use + .list() + .pipe(provideInstanceEffect(directory)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) + +it.effect("redrob provider reports source launcher when only the shared file holds a key", () => + Effect.gen(function* () { + yield* withOwnAuth(undefined) + yield* withSharedAccount(launcherAccount) + const providers = yield* listIn(yield* tmpdirScoped()) + const redrob = providers[ProviderV2.ID.redrob] + expect(redrob).toBeDefined() + expect(redrob.source).toBe("launcher") + expect(redrob.key).toBe("rk_from_launcher") + expect(redrob.options.apiKey).toBeUndefined() + }).pipe(provideMultiInstance), +) + +it.effect("redrob provider stays keyless with the public placeholder when the shared file is absent or empty", () => + Effect.gen(function* () { + yield* withOwnAuth(undefined) + yield* withSharedAccount({ apiKey: "" }) + const providers = yield* listIn(yield* tmpdirScoped()) + const redrob = providers[ProviderV2.ID.redrob] + expect(redrob).toBeDefined() + expect(redrob.source).toBe("custom") + expect(redrob.key).toBeUndefined() + expect(redrob.options.apiKey).toBe("public") + }).pipe(provideMultiInstance), +) + +it.effect("REDROB_API_KEY wins over the shared file in the provider report", () => + Effect.gen(function* () { + yield* withOwnAuth(undefined) + yield* withSharedAccount(launcherAccount) + yield* setProcessEnv("REDROB_API_KEY", "rk_from_env") + const providers = yield* listIn(yield* tmpdirScoped()) + const redrob = providers[ProviderV2.ID.redrob] + expect(redrob.source).toBe("env") + expect(redrob.key).toBe("rk_from_env") + }).pipe(provideMultiInstance), +) + +it.effect("the app's own auth.json wins over the shared file, and removing it falls back to the launcher", () => + Effect.gen(function* () { + yield* withSharedAccount(launcherAccount) + const authPath = yield* withOwnAuth({ type: "api", key: "rk_from_auth_json" }) + + const own = (yield* listIn(yield* tmpdirScoped()))[ProviderV2.ID.redrob] + expect(own.source).toBe("api") + expect(own.key).toBe("rk_from_auth_json") + + // What `DELETE /auth/redrob` does. The shared file is not touched, so the next list() falls back to it. + yield* Effect.promise(() => unlink(authPath)) + const fallback = (yield* listIn(yield* tmpdirScoped()))[ProviderV2.ID.redrob] + expect(fallback.source).toBe("launcher") + expect(fallback.key).toBe("rk_from_launcher") + }).pipe(provideMultiInstance), +) diff --git a/packages/redrob/test/server/chat-completion-labels.test.ts b/packages/redrob/test/server/chat-completion-labels.test.ts new file mode 100644 index 0000000000..7f5ad395ef --- /dev/null +++ b/packages/redrob/test/server/chat-completion-labels.test.ts @@ -0,0 +1,29 @@ +/** + * Caller labels on `POST /v1/chat/completions`. + * + * A shared daemon serves several apps at once, so the app and session a request belongs to must + * travel on the request, not in the engine's environment. The handler reads `x-redrob-app` and + * `x-redrob-session` and forwards them to the provider under the same names the agent runner uses, + * so the Console groups both paths the same way. Nothing else is forwarded. + */ +import { describe, expect, test } from "bun:test" +import { APP_HEADER, SESSION_HEADER, callerLabels } from "@redrob-code/server/handlers/chat-completion" + +describe("chat completions caller labels", () => { + test("app and session headers are forwarded under the runner's names", () => { + expect(callerLabels({ [APP_HEADER]: "shape", [SESSION_HEADER]: "ses_1" })).toEqual({ + "x-redrob-app": "shape", + "X-Session-Id": "ses_1", + "x-session-affinity": "ses_1", + }) + }) + + test("absent or blank headers send nothing, and other headers are not forwarded", () => { + expect(callerLabels({})).toEqual({}) + expect(callerLabels({ [APP_HEADER]: " ", authorization: "Basic x", "x-forwarded-for": "1.2.3.4" })).toEqual({}) + }) + + test("the app alone is enough", () => { + expect(callerLabels({ [APP_HEADER]: " canvas " })).toEqual({ "x-redrob-app": "canvas" }) + }) +}) diff --git a/packages/redrob/test/server/chat-completion-mask.test.ts b/packages/redrob/test/server/chat-completion-mask.test.ts new file mode 100644 index 0000000000..2f49e86544 --- /dev/null +++ b/packages/redrob/test/server/chat-completion-mask.test.ts @@ -0,0 +1,83 @@ +/** + * PII masking on `POST /v1/chat/completions`. + * + * The runner test proves the switch for agent sessions; this one proves the same masker guards the + * OpenAI-compatible route, which does not go through the runner. A stub provider records what it + * was sent and answers with a placeholder: the provider must see `[EMAIL_1]`, the caller must read + * the real address back, in both the streamed and the assembled form. + */ +import { describe, expect, test } from "bun:test" +import { LLMClient, LLMEvent, LLM, Message, Model, type LLMClientShape, type LLMRequest } from "@redrob-code/llm" +import * as OpenAIChat from "@redrob-code/llm/protocols/openai-chat" +import { Privacy } from "@redrob-code/core/privacy/service" +import { maskedGenerate, maskedRequest, maskedStream } from "@redrob-code/server/handlers/chat-completion" +import { Effect, Layer, Stream } from "effect" + +const sent: LLMRequest[] = [] +const answer = "Sure, I will write to [EMAIL_1] today." +const client = Layer.succeed( + LLMClient.Service, + LLMClient.Service.of({ + prepare: () => Effect.die("unused"), + stream: ((request: LLMRequest) => { + sent.push(request) + return Stream.fromIterable([ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "t" }), + LLMEvent.textDelta({ id: "t", text: answer.slice(0, 20) }), + LLMEvent.textDelta({ id: "t", text: answer.slice(20) }), + LLMEvent.textEnd({ id: "t" }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ]) + }) as unknown as LLMClientShape["stream"], + generate: () => Effect.die("generate must go through the unmasking stream"), + }), +) +const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) +const request = () => + LLM.request({ model, messages: [Message.user("Mail jiwon.kim@example.com the invoice")], tools: [] }) + +const run = (mode: "off" | "regex", effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(Layer.mergeAll(client, Privacy.layerWith({ mode })))) as Effect.Effect) + +describe("chat/completions masking", () => { + test("off: the request goes out as written and nothing is rewritten", async () => { + sent.length = 0 + const { request: outgoing, unmask } = await run("off", maskedRequest(request())) + expect(unmask).toBeUndefined() + expect(JSON.stringify(outgoing.messages)).toContain("jiwon.kim@example.com") + }) + + test("regex: the provider sees a placeholder and the streamed answer carries the real value", async () => { + sent.length = 0 + const texts = await run( + "regex", + Effect.gen(function* () { + const { request: outgoing, unmask } = yield* maskedRequest(request()) + expect(unmask).toBeDefined() + return yield* maskedStream(outgoing, unmask).pipe( + Stream.filter(LLMEvent.is.textDelta), + Stream.map((event) => event.text), + Stream.runCollect, + ) + }), + ) + expect(JSON.stringify(sent[0]!.messages)).not.toContain("jiwon.kim@example.com") + expect(JSON.stringify(sent[0]!.messages)).toContain("[EMAIL_1]") + expect([...texts].join("")).toBe("Sure, I will write to jiwon.kim@example.com today.") + }) + + test("regex: the assembled (non-stream) answer is unmasked too", async () => { + sent.length = 0 + const response = await run( + "regex", + Effect.gen(function* () { + const { request: outgoing, unmask } = yield* maskedRequest(request()) + return yield* maskedGenerate(outgoing, unmask) + }), + ) + expect(response.text).toBe("Sure, I will write to jiwon.kim@example.com today.") + expect(JSON.stringify(sent[0]!.messages)).toContain("[EMAIL_1]") + }) +}) diff --git a/packages/redrob/test/server/chat-completion-route.test.ts b/packages/redrob/test/server/chat-completion-route.test.ts new file mode 100644 index 0000000000..8957023db6 --- /dev/null +++ b/packages/redrob/test/server/chat-completion-route.test.ts @@ -0,0 +1,72 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@redrob-code/core/flag/flag" +import { describe, expect } from "bun:test" +import { Config, Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../fixture/db" +import { testEffect } from "../lib/effect" + +// The OpenAI-compatible route is what every Redrob app (Grow, Canvas, Query, Recall, Office) calls +// on the shared daemon. v0.6.4 shipped with the route's service graph missing the LLM client, so +// every call -- with or without tools, any model -- died with a 500 "Service not found: +// @redrob/LLMClient" before a provider was even chosen. This test drives the production route tree +// and only asserts the one thing that bug broke: the request reaches the model layer. Whatever +// the provider answers without a credential is fine; a missing service is not. + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { REDROB_SERVER_PASSWORD: Flag.REDROB_SERVER_PASSWORD } + Flag.REDROB_SERVER_PASSWORD = "secret" + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.REDROB_SERVER_PASSWORD = original.REDROB_SERVER_PASSWORD + await resetDatabase() + }), + ) + }), +) + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { disableListenLog: true, disableLogger: true }, +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + ), +) + +const basic = "Basic " + Buffer.from("redrob:secret").toString("base64") + +describe("POST /v1/chat/completions", () => { + it.live("reaches the model layer instead of failing on a missing LLM client service", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post("/v1/chat/completions").pipe( + HttpClientRequest.setHeaders({ authorization: basic, "x-redrob-app": "grow" }), + HttpClientRequest.bodyJson({ + model: "redrob/auto", + messages: [{ role: "user", content: "say OK" }], + tools: [ + { + type: "function", + function: { name: "note", description: "record a note", parameters: { type: "object", properties: {} } }, + }, + ], + }), + Effect.flatMap(HttpClient.execute), + ) + const text = yield* response.text + expect(text).not.toContain("Service not found") + expect(response.status).not.toBe(500) + }), + ) +}) diff --git a/packages/redrob/test/server/chat-completion-wire.test.ts b/packages/redrob/test/server/chat-completion-wire.test.ts new file mode 100644 index 0000000000..b2dde1ffd6 --- /dev/null +++ b/packages/redrob/test/server/chat-completion-wire.test.ts @@ -0,0 +1,242 @@ +/** + * Contract: the OpenAI wire format converts to this engine's LLM schema without + * inventing, dropping, or silently reinterpreting anything a caller sent. + * + * These are the conversions that carry the real risk on POST /v1/chat/completions, + * because the format is fixed by clients that already exist and a mistranslation + * is invisible: the request succeeds and the model is simply given something else + * than the caller wrote. + */ +import { describe, expect, it } from "bun:test" +import type { ChatCompletionRequest } from "@redrob-code/protocol/groups/chat-completion" +import { + ConversionError, + toFinishReason, + toGeneration, + toLLMMessages, + toLLMTools, + toWireToolCalls, +} from "@redrob-code/server/handlers/chat-completion-wire" + +const base = { model: "redrob/auto" } as const + +function request(fields: Partial): ChatCompletionRequest { + return { ...base, messages: [], ...fields } as ChatCompletionRequest +} + +describe("messages", () => { + it("carries a plain exchange through in order", () => { + const messages = toLLMMessages( + request({ + messages: [ + { role: "system", content: "be brief" }, + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + ], + }), + ) + expect(messages.map((m) => m.role)).toEqual(["system", "user", "assistant"]) + expect(messages[1]!.content).toEqual([{ type: "text", text: "hello" }]) + }) + + it("treats developer as system, because OpenAI renamed the role and kept both", () => { + const messages = toLLMMessages(request({ messages: [{ role: "developer", content: "rules" }] })) + expect(messages).toHaveLength(1) + expect(messages[0]!.role).toBe("system") + }) + + it("drops an empty message instead of sending a blank turn", () => { + expect(toLLMMessages(request({ messages: [{ role: "system", content: "" }] }))).toHaveLength(0) + expect(toLLMMessages(request({ messages: [{ role: "assistant", content: null }] }))).toHaveLength(0) + }) + + it("parses tool_call arguments, which arrive as a JSON string and must not stay one", () => { + const messages = toLLMMessages( + request({ + messages: [ + { + role: "assistant", + tool_calls: [ + { id: "call_1", type: "function", function: { name: "read", arguments: '{"path":"a.txt"}' } }, + ], + }, + ], + }), + ) + const part = messages[0]!.content[0] as { type: string; id: string; name: string; input: unknown } + expect(part.type).toBe("tool-call") + expect(part.id).toBe("call_1") + expect(part.input).toEqual({ path: "a.txt" }) + }) + + it("keeps text and tool calls together on one assistant turn", () => { + const messages = toLLMMessages( + request({ + messages: [ + { + role: "assistant", + content: "let me look", + tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }], + }, + ], + }), + ) + expect(messages[0]!.content.map((part) => part.type)).toEqual(["text", "tool-call"]) + }) + + it("refuses malformed tool_call arguments rather than forwarding an empty object", () => { + // Silently sending {} would make the model answer a question nobody asked. + expect(() => + toLLMMessages( + request({ + messages: [ + { + role: "assistant", + tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{not json" } }], + }, + ], + }), + ), + ).toThrow(ConversionError) + }) + + it("refuses a tool message with no tool_call_id", () => { + // The provider pairs the result to the call by this id; without it the model + // sees an orphan result and there is nothing sensible to guess. + expect(() => toLLMMessages(request({ messages: [{ role: "tool", content: "done" }] }))).toThrow( + /requires tool_call_id/, + ) + }) + + it("carries a tool result under the id it answers", () => { + const messages = toLLMMessages( + request({ messages: [{ role: "tool", tool_call_id: "c1", name: "read", content: "file body" }] }), + ) + const part = messages[0]!.content[0] as { type: string; id: string } + expect(messages[0]!.role).toBe("tool") + expect(part.type).toBe("tool-result") + expect(part.id).toBe("c1") + }) + + it("accepts an array content part of type text", () => { + const messages = toLLMMessages( + request({ messages: [{ role: "user", content: [{ type: "text", text: "from a part" }] }] }), + ) + expect(messages[0]!.content).toEqual([{ type: "text", text: "from a part" }]) + }) + + it("refuses a content part it cannot carry instead of dropping it", () => { + // An image silently discarded looks to the user like the model ignored it. + expect(() => + toLLMMessages( + request({ + messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:..." } }] }], + }), + ), + ).toThrow(/not supported/) + }) +}) + +describe("tools", () => { + it("is undefined when the caller declared none, which is what selects engine-owned tools", () => { + expect(toLLMTools(request({}))).toBeUndefined() + expect(toLLMTools(request({ tools: [] }))).toBeUndefined() + }) + + it("converts a declaration and passes parameters through untouched", () => { + const parameters = { type: "object", properties: { path: { type: "string" } }, required: ["path"] } + const tools = toLLMTools( + request({ tools: [{ type: "function", function: { name: "read", description: "read a file", parameters } }] }), + ) + expect(tools!.definitions).toHaveLength(1) + expect(tools!.definitions[0]!.name).toBe("read") + expect(tools!.definitions[0]!.inputSchema).toEqual(parameters) + }) + + it("gives a parameterless tool an empty object schema rather than dropping it", () => { + const tools = toLLMTools(request({ tools: [{ type: "function", function: { name: "now" } }] })) + expect(tools!.definitions[0]!.inputSchema).toEqual({ type: "object", properties: {} }) + // An invented description would put words in the prompt the caller never wrote. + expect(tools!.definitions[0]!.description).toBe("") + }) + + it("carries tool_choice as the engine's shape, never as a bare string", () => { + // A bare string is ambiguous: the engine's normalizer reads + // "auto"/"none"/"required" as modes and anything else as a tool name, so a + // tool actually named `auto` would silently become a mode. + expect( + toLLMTools(request({ tools: [{ type: "function", function: { name: "a" } }], tool_choice: "none" }))!.choice, + ).toEqual({ type: "none" }) + expect( + toLLMTools( + request({ + tools: [{ type: "function", function: { name: "a" } }], + tool_choice: { type: "function", function: { name: "a" } }, + }), + )!.choice, + ).toEqual({ type: "tool", name: "a" }) + }) + + it("names a tool called auto as a tool, not as the auto mode", () => { + expect( + toLLMTools( + request({ + tools: [{ type: "function", function: { name: "auto" } }], + tool_choice: { type: "function", function: { name: "auto" } }, + }), + )!.choice, + ).toEqual({ type: "tool", name: "auto" }) + }) +}) + +describe("finish reason", () => { + it("maps the engine's vocabulary onto OpenAI's", () => { + expect(toFinishReason("stop")).toBe("stop") + expect(toFinishReason("length")).toBe("length") + expect(toFinishReason("tool-calls")).toBe("tool_calls") + expect(toFinishReason("content-filter")).toBe("content_filter") + expect(toFinishReason(undefined)).toBeNull() + }) + + it("does not report a failed turn as a clean stop", () => { + // "error" and "unknown" have no OpenAI equivalent. Reporting "stop" would + // tell the caller the turn ended normally when it did not. + expect(toFinishReason("error")).not.toBe("stop") + expect(toFinishReason("unknown")).not.toBe("stop") + }) +}) + +describe("tool calls out", () => { + it("re-stringifies input, because the engine parses it and clients expect a string", () => { + const wire = toWireToolCalls([{ id: "c1", name: "read", input: { path: "a.txt" } }]) + expect(wire[0]!.function.arguments).toBe('{"path":"a.txt"}') + expect(JSON.parse(wire[0]!.function.arguments)).toEqual({ path: "a.txt" }) + expect(wire[0]!.type).toBe("function") + }) + + it("emits {} for a call with no input, never undefined", () => { + // `arguments: undefined` breaks a client that calls JSON.parse on it. + expect(toWireToolCalls([{ id: "c1", name: "now", input: undefined }])[0]!.function.arguments).toBe("{}") + }) +}) + +describe("generation options", () => { + it("is undefined when the caller set none", () => { + expect(toGeneration(request({}))).toBeUndefined() + }) + + it("renames the wire fields to the engine's", () => { + const generation = toGeneration(request({ max_tokens: 256, temperature: 0.2, top_p: 0.9 })) + expect(generation).toEqual({ maxTokens: 256, temperature: 0.2, topP: 0.9 }) + }) + + it("lets max_tokens win over max_completion_tokens when both are sent", () => { + expect(toGeneration(request({ max_tokens: 100, max_completion_tokens: 900 }))!["maxTokens"]).toBe(100) + expect(toGeneration(request({ max_completion_tokens: 900 }))!["maxTokens"]).toBe(900) + }) + + it("normalizes a single stop string to a list", () => { + expect(toGeneration(request({ stop: "END" }))!["stop"]).toEqual(["END"]) + expect(toGeneration(request({ stop: ["A", "B"] }))!["stop"]).toEqual(["A", "B"]) + }) +}) diff --git a/packages/redrob/test/server/httpapi-chat-completion.test.ts b/packages/redrob/test/server/httpapi-chat-completion.test.ts new file mode 100644 index 0000000000..25dac31b9f --- /dev/null +++ b/packages/redrob/test/server/httpapi-chat-completion.test.ts @@ -0,0 +1,147 @@ +/** + * Route tests for `POST /v1/chat/completions`, driven in-process against the real + * app through `httpapi-layer`. + * + * Scope note. These tests cover what can be asserted without a provider + * credential: that the route is served on the v2 surface at all, that a body + * decodes against `ChatCompletionRequest`, that every failure comes back in the + * OpenAI error envelope rather than Effect's default shape, and that the v1 + * contract's field names are actually present. A completion that reaches a model + * needs a credential this suite has none of, so the streaming happy path is + * covered by `chat-completion-wire.test.ts` at the conversion layer instead of + * being faked here. + * + * The envelope assertions are the point. A caller written against OpenAI's API + * reads `error.message` and `error.type`; if this route ever returns Effect's + * `{ name, data }` shape instead, every such caller silently sees an + * error-less response and reports a truncated or empty answer rather than a + * failure. + */ +import { describe, expect } from "bun:test" +import { LayerNode } from "@redrob-code/core/effect/layer-node" +import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" +import { Session } from "@/session/session" +import { Database } from "@redrob-code/core/database/database" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) + +const PATH = "/v1/chat/completions" + +function post(directory: string, body: unknown) { + return requestInDirectory(PATH, directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) +} + +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((value) => value as T)) +} + +type ErrorEnvelope = { + error?: { message?: unknown; type?: unknown; code?: unknown; param?: unknown } +} + +describe("POST /v1/chat/completions", () => { + it.instance("is served on the v2 surface", () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* post(tmp.directory, { + model: "no-such-provider/no-such-model", + messages: [{ role: "user", content: "ping" }], + }) + + // Any status other than 404 proves the route exists and is reached. The + // specific failure is asserted below; this case exists so a registration + // regression (the group dropped from protocol/src/api.ts) fails loudly and + // separately from a behaviour change. + expect(response.status).not.toBe(404) + }), + ) + + it.instance("reports an unknown model in the OpenAI error envelope", () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* post(tmp.directory, { + model: "no-such-provider/no-such-model", + messages: [{ role: "user", content: "ping" }], + }) + + expect(response.status).toBe(400) + const body = yield* json(response) + expect(typeof body.error).toBe("object") + expect(typeof body.error?.message).toBe("string") + expect(typeof body.error?.type).toBe("string") + // Effect's default failure shape must NOT leak through. + expect(body).not.toHaveProperty("name") + expect(body).not.toHaveProperty("data") + }), + ) + + it.instance("rejects a body that is not a chat-completions request", () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* post(tmp.directory, { model: "x" }) + + expect(response.status).toBe(400) + const body = yield* json(response) + expect(typeof body.error?.message).toBe("string") + }), + ) + + it.instance("rejects an empty message list rather than calling a provider", () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* post(tmp.directory, { model: "x/y", messages: [] }) + + expect(response.status).toBe(400) + }), + ) + + // v1 CONTRACT. This is the test that should fail when someone removes or renames + // a field downstream callers read. It asserts the request shape is still + // ACCEPTED (decode succeeds, so the failure is about the model rather than the + // body) for every field the v1 contract promises, including the tools path that + // `docs/LOCAL-ENGINE-API.md` describes as caller-owned. + it.instance("still accepts every field the v1 contract promises", () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const response = yield* post(tmp.directory, { + model: "no-such-provider/no-such-model", + messages: [ + { role: "system", content: "be brief" }, + { role: "user", content: "ping" }, + { role: "assistant", content: "pong" }, + ], + stream: true, + temperature: 0.2, + max_tokens: 64, + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Look up the weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + tool_choice: "auto", + }) + + // 400 for the unknown model, NOT a decode failure: if a promised field had + // been dropped from the schema this would still be 400 but with a decode + // message, so the body is checked for the model rather than the shape. + expect(response.status).toBe(400) + const body = yield* json(response) + const message = String(body.error?.message ?? "") + expect(message.toLowerCase()).not.toContain("expected") + expect(message.toLowerCase()).not.toContain("is missing") + }), + ) +}) diff --git a/packages/redrob/test/server/httpapi-exercise/index.ts b/packages/redrob/test/server/httpapi-exercise/index.ts index 551550e90c..3e22c0f895 100644 --- a/packages/redrob/test/server/httpapi-exercise/index.ts +++ b/packages/redrob/test/server/httpapi-exercise/index.ts @@ -18,6 +18,7 @@ * - `.mutating()` tells the runner to reset isolated state after destructive routes. */ import { Effect } from "effect" +import * as EngineClients from "@redrob-code/server/clients" import { OpenApi } from "effect/unstable/httpapi" import { TestLLMServer } from "../../lib/llm-server" import path from "path" @@ -97,9 +98,7 @@ const scenarios: Scenario[] = [ Effect.gen(function* () { object(body) check(body.username === "httpapi-global", "global config update should return patched config") - const text = yield* Effect.promise(() => - Bun.file(path.join(exerciseConfigDirectory, "redrob.jsonc")).text(), - ) + const text = yield* Effect.promise(() => Bun.file(path.join(exerciseConfigDirectory, "redrob.jsonc")).text()) check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") }), "status", @@ -581,7 +580,33 @@ const scenarios: Scenario[] = [ http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => { check(typeof body === "object" && body !== null, "capabilities should be an object") check("backgroundSubagents" in body, "capabilities should report background subagents") + check("chatCompletions" in body, "capabilities should report the chat-completions contract") + check("sharedDaemon" in body, "capabilities should report the shared-daemon contract") }), + // The OpenAI-compatible route. Exercised on its 400 path deliberately: a real + // completion needs a provider credential this harness has none of, so asserting + // 200 would make the gate depend on the machine it runs on. An unknown model id + // is refused before any provider is resolved, which is what makes this + // deterministic -- and it still covers the parts most likely to break, namely + // that the body decodes against ChatCompletionRequest and that the failure comes + // back in the OpenAI error envelope rather than Effect's default shape. + http.protected + .post("/v1/chat/completions", "chat.completions") + .at((ctx) => ({ + path: "/v1/chat/completions", + headers: ctx.headers(), + body: { + model: "redrob-httpapi-exercise/no-such-model", + messages: [{ role: "user", content: "ping" }], + }, + })) + .json(400, (body) => { + check(typeof body === "object" && body !== null, "chat completions error should be an object") + const error = (body as { error?: { message?: unknown; type?: unknown } }).error + check(typeof error === "object" && error !== null, "error should be nested under `error`, as OpenAI does") + check(typeof error?.message === "string", "error should carry a message") + check(typeof error?.type === "string", "error should carry a type") + }), http.protected .post("/experimental/session/{sessionID}/background", "experimental.session.background") .mutating() @@ -626,6 +651,17 @@ const scenarios: Scenario[] = [ .json(200, (body) => { check(body === true, "log route should return true") }), + /* No Redrob key in the exercise home, so both media routes refuse up front with 503, spending nothing. */ + http.protected + .post("/redrob/speech", "redrob.speech") + .global() + .at(() => ({ path: "/redrob/speech", body: { text: "route coverage" } })) + .status(503, undefined, "status"), + http.protected + .post("/redrob/transcribe", "redrob.transcribe") + .global() + .at(() => ({ path: "/redrob/transcribe", body: { audio: "UklGRg==", format: "wav" } })) + .status(503, undefined, "status"), http.protected .put("/auth/{providerID}", "auth.set") .global() @@ -727,6 +763,50 @@ const scenarios: Scenario[] = [ body: { label: "Work" }, })) .status(204, undefined, "status"), + // The shared daemon's attachment registry. Process-level state, so each scenario seeds its own + // attachment and the list is asserted on shape, not on a count another scenario may have moved. + http.protected.get("/api/clients", "v2.clients.list").json(200, (body) => { + object(body) + check(Array.isArray(body.clients), "clients should be a list") + check(typeof body.ttlMs === "number", "the heartbeat deadline should be reported") + }), + http.protected + .post("/api/clients", "v2.clients.attach") + .mutating() + .at((ctx) => ({ path: "/api/clients", headers: ctx.headers(), body: { app: "httpapi", pid: 4242 } })) + .json(200, (body) => { + object(body) + check(typeof body.id === "string" && body.app === "httpapi" && body.pid === 4242, "the attachment should be echoed with an id") + }), + http.protected + .post("/api/clients/{clientID}/heartbeat", "v2.clients.heartbeat") + .mutating() + .seeded(() => Effect.sync(() => EngineClients.attach({ app: "httpapi-heartbeat" }))) + .at((ctx) => ({ + path: route("/api/clients/{clientID}/heartbeat", { clientID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "the heartbeat should answer with the same attachment") + }), + http.protected + .delete("/api/clients/{clientID}", "v2.clients.detach") + .mutating() + .seeded(() => Effect.sync(() => EngineClients.attach({ app: "httpapi-detach" }))) + .at((ctx) => ({ path: route("/api/clients/{clientID}", { clientID: ctx.state.id }), headers: ctx.headers() })) + .status(204, undefined, "status"), + http.protected.get("/api/privacy", "v2.privacy.get").json(200, (body) => { + object(body) + check(body.mode === "off", "masking should be off until someone turns it on") + }), + http.protected + .put("/api/privacy", "v2.privacy.set") + .at((ctx) => ({ path: "/api/privacy", headers: ctx.headers(), body: { mode: "off" } })) + .json(200, (body) => { + object(body) + check(body.mode === "off", "the setting should be echoed back") + }), http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)), http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)), http.protected @@ -896,6 +976,47 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(404, object, "status"), + // PA-10's four, mirroring the V2 question scenarios above because the browser request + // service is deliberately the same shape: a global list, a per-session list, and the two + // settlement routes. The settlements name a request id that does not exist, so each + // asserts the 404 path rather than needing a live browser on the other end. + http.protected.get("/api/browser/request", "v2.browser.request.list").json(200, (body) => { + object(body) + object(body.location) + array(body.data) + }), + http.protected + .get("/api/session/{sessionID}/browser", "v2.session.browser.list") + .seeded((ctx) => ctx.session({ title: "Browser request list owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/browser", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, data(array)), + http.protected + .post("/api/session/{sessionID}/browser/{requestID}/reply", "v2.session.browser.reply") + .seeded((ctx) => ctx.session({ title: "Browser request reply owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/browser/{requestID}/reply", { + sessionID: ctx.state.id, + requestID: "brq_httpapi_missing", + }), + headers: ctx.headers(), + body: { value: { type: "string", value: "" } }, + })) + .json(404, object, "status"), + http.protected + .post("/api/session/{sessionID}/browser/{requestID}/refuse", "v2.session.browser.refuse") + .seeded((ctx) => ctx.session({ title: "Browser request refuse owner" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/browser/{requestID}/refuse", { + sessionID: ctx.state.id, + requestID: "brq_httpapi_missing", + }), + headers: ctx.headers(), + body: { reason: "no page executor" }, + })) + .json(404, object, "status"), http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, (body) => { object(body) array(body.data) diff --git a/packages/redrob/test/server/httpapi-public-openapi.test.ts b/packages/redrob/test/server/httpapi-public-openapi.test.ts index 310ebae595..9b43c09aec 100644 --- a/packages/redrob/test/server/httpapi-public-openapi.test.ts +++ b/packages/redrob/test/server/httpapi-public-openapi.test.ts @@ -10,6 +10,8 @@ type OpenApiSchema = { readonly enum?: readonly unknown[] readonly properties?: Record readonly required?: readonly string[] + /** Element schema of an array property, e.g. a chunk's `choices`. */ + readonly items?: OpenApiSchema readonly contentSchema?: OpenApiSchema readonly contentMediaType?: string } @@ -70,6 +72,49 @@ function isBuiltInEndpointError(name: string) { } describe("PublicApi OpenAPI v2 errors", () => { + test("documents both content types on the chat-completions 200", () => { + // The route answers with JSON or with text/event-stream depending on `stream`, and + // HttpApi can only declare one success schema. Without the patch the spec describes + // only the JSON, so a generated client looks for choices[].message.content on a + // streaming response and finds nothing on every frame -- a chunk carries + // choices[].delta instead. + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + const response = spec.paths["/v1/chat/completions"]?.post?.responses?.["200"] + + expect(Object.keys(response?.content ?? {}).sort()).toEqual(["application/json", "text/event-stream"]) + // Both, not one replacing the other: dropping the JSON would lie in the other direction. + expect(response?.content?.["application/json"]).toBeDefined() + expect(response?.description).toContain("[DONE]") + }) + + test("the chat-completions SSE ref resolves to a registered component", () => { + // A $ref to a schema no endpoint references dangles unless it is registered through + // HttpApi.AdditionalSchemas. A dangling ref generates a client with a missing type + // rather than failing loudly, so this is the assertion that keeps the patch honest. + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + const ref = spec.paths["/v1/chat/completions"]?.post?.responses?.["200"]?.content?.[ + "text/event-stream" + ]?.schema?.$ref + + expect(ref).toBe("#/components/schemas/ChatCompletionChunk") + expect(Object.keys(spec.components.schemas)).toContain("ChatCompletionChunk") + }) + + test("a chunk is shaped as a delta, not as a complete message", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + const chunk = spec.components.schemas.ChatCompletionChunk + const choice = chunk?.properties?.choices?.items as OpenApiSchema | undefined + + expect(choice?.properties?.delta).toBeDefined() + // The discriminator differs from the non-streaming object, which is the whole reason a + // separate schema was needed. + expect(chunk?.properties?.object?.enum).toEqual(["chat.completion.chunk"]) + // Every delta field is optional: the first frame carries only role, middle frames only + // content, and the terminator an empty delta. Requiring content would reject the + // terminator. + expect(choice?.properties?.delta?.required ?? []).toEqual([]) + }) + test("includes plugin-facing core schemas", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec diff --git a/packages/redrob/test/server/httpapi-speech.test.ts b/packages/redrob/test/server/httpapi-speech.test.ts new file mode 100644 index 0000000000..de7aa1fb2f --- /dev/null +++ b/packages/redrob/test/server/httpapi-speech.test.ts @@ -0,0 +1,184 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Config, Context, Effect, Layer } from "effect" +import { + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import * as Http from "node:http" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { DEFAULT_SPEECH_MODEL, DEFAULT_SPEECH_VOICE, PLAYBACK_SPEECH_MODEL } from "../../src/tool/speech_generate" +import { testEffect } from "../lib/effect" + +/** + * `POST /redrob/speech` through the production route tree, with a stand-in for the Redrob gateway + * behind it. What matters is that the app gets audio while the engine's key only ever goes to the + * gateway, and that every refusal arrives as the endpoint's declared error. + */ + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { disableListenLog: true, disableLogger: true }, +) +const it = testEffect( + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), +) + +const MP3 = new Uint8Array([0xff, 0xf3, 0x44, 0xc4, 0x01, 0x02]) + +type Seen = { authorization: string | undefined; body: unknown } +type Answer = { status: number; type: string; body: Uint8Array | string } + +/** The engine pointed at the stand-in and holding a Redrob key, restored when the test scope closes. */ +const engineEnv = (gatewayUrl: string) => + Effect.acquireRelease( + Effect.sync(() => { + const saved = { + url: process.env.REDROB_CONSOLE_URL, + auth: process.env.REDROB_AUTH_CONTENT, + key: process.env.REDROB_API_KEY, + } + process.env.REDROB_CONSOLE_URL = gatewayUrl + process.env.REDROB_AUTH_CONTENT = JSON.stringify({ redrob: { type: "api", key: "rk_engine_key" } }) + delete process.env.REDROB_API_KEY + return saved + }), + (saved) => + Effect.sync(() => { + for (const [name, value] of [ + ["REDROB_CONSOLE_URL", saved.url], + ["REDROB_AUTH_CONTENT", saved.auth], + ["REDROB_API_KEY", saved.key], + ] as const) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + }), + ) + +/** + * Topology: the engine's routes run on the test's primary server (NodeHttpServer.layerTest), and the + * Redrob gateway stand-in is a second listener built into this test's scope with its own fresh router, + * so its `/audio/speech` cannot collide with anything the engine registers. The engine reaches the + * stand-in through `REDROB_CONSOLE_URL`, the developer seam the gateway URL already honours. + */ +const gateway = Effect.fn("SpeechTest.gateway")(function* (answer: (body: Record) => Answer) { + const seen: Seen[] = [] + const context = yield* Layer.build( + Layer.mergeAll( + Layer.fresh(HttpRouter.layer), + NodeHttpServer.layer(() => Http.createServer(), { host: "127.0.0.1", port: 0 }), + ), + ) + const server = Context.get(context, HttpServer.HttpServer) + const router = Context.get(context, HttpRouter.HttpRouter) + yield* router.add( + "POST", + "/audio/speech", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const raw = yield* request.json.pipe(Effect.orElseSucceed(() => ({}))) + const body = typeof raw === "object" && raw !== null ? Object.fromEntries(Object.entries(raw)) : {} + seen.push({ authorization: request.headers.authorization, body }) + const reply = answer(body) + return typeof reply.body === "string" + ? HttpServerResponse.text(reply.body, { status: reply.status, contentType: reply.type }) + : HttpServerResponse.uint8Array(reply.body, { status: reply.status, contentType: reply.type }) + }), + ) + yield* server.serve(router.asHttpEffect()) + if (server.address._tag !== "TcpAddress") throw new Error("expected a TCP listener") + yield* engineEnv(`http://127.0.0.1:${server.address.port}`) + return { seen } +}) + +const speak = (payload: Record) => + HttpClientRequest.post(ControlPaths.speech).pipe( + HttpClientRequest.bodyJson(payload), + Effect.flatMap(HttpClient.execute), + ) + +describe("redrob speech HttpApi", () => { + it.live("answers with the gateway's mp3, sending the engine's key only to the gateway", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => ({ status: 200, type: "audio/mpeg", body: MP3 })) + const response = yield* speak({ text: "안녕하세요" }) + + expect(response.status).toBe(200) + expect(response.headers["content-type"]).toContain("audio/mpeg") + expect(new Uint8Array(yield* response.arrayBuffer)).toEqual(MP3) + expect(seen).toEqual([ + { + authorization: "Bearer rk_engine_key", + body: { + model: PLAYBACK_SPEECH_MODEL, + input: "안녕하세요", + response_format: "mp3", + voice: DEFAULT_SPEECH_VOICE, + }, + }, + ]) + }), + ) + + it.live("plays with the fast model by default, and a chosen model keeps the voice it was given", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => ({ status: 200, type: "audio/mpeg", body: MP3 })) + expect((yield* speak({ text: "One" })).status).toBe(200) + expect((yield* speak({ text: "Two", model: DEFAULT_SPEECH_MODEL })).status).toBe(200) + expect((yield* speak({ text: "Three", model: DEFAULT_SPEECH_MODEL, voice: "george" })).status).toBe(200) + expect(seen.map((call) => call.body)).toEqual([ + { model: PLAYBACK_SPEECH_MODEL, input: "One", response_format: "mp3", voice: DEFAULT_SPEECH_VOICE }, + // Another model's voices are its own, so none is assumed for it. + { model: DEFAULT_SPEECH_MODEL, input: "Two", response_format: "mp3" }, + { model: DEFAULT_SPEECH_MODEL, input: "Three", response_format: "mp3", voice: "george" }, + ]) + }), + ) + + it.live("refuses empty or over-long text with a 400, sending nothing", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => ({ status: 200, type: "audio/mpeg", body: MP3 })) + expect((yield* speak({ text: " " })).status).toBe(400) + expect((yield* speak({ text: "가".repeat(4097) })).status).toBe(400) + expect(seen).toHaveLength(0) + }), + ) + + it.live("without a Redrob key it is a 503, sending nothing", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => ({ status: 200, type: "audio/mpeg", body: MP3 })) + process.env.REDROB_AUTH_CONTENT = JSON.stringify({}) + const response = yield* speak({ text: "Hello" }) + expect(response.status).toBe(503) + expect(JSON.stringify(yield* response.json)).toContain("Connect Redrob") + expect(seen).toHaveLength(0) + }), + ) + + it.live("a gateway refusal, or an answer that is not audio, is a 502 with the gateway's message", () => + Effect.gen(function* () { + yield* gateway((body) => + body.input === "json" + ? { status: 200, type: "application/json", body: "{}" } + : { status: 402, type: "application/json", body: JSON.stringify({ error: { message: "Out of credit." } }) }, + ) + const refused = yield* speak({ text: "Hello" }) + expect(refused.status).toBe(502) + expect(JSON.stringify(yield* refused.json)).toContain("answered 402: Out of credit.") + const json = yield* speak({ text: "json" }) + expect(json.status).toBe(502) + expect(JSON.stringify(yield* json.json)).toContain("not audio") + }), + ) +}) diff --git a/packages/redrob/test/server/httpapi-transcribe.test.ts b/packages/redrob/test/server/httpapi-transcribe.test.ts new file mode 100644 index 0000000000..2760cf91f0 --- /dev/null +++ b/packages/redrob/test/server/httpapi-transcribe.test.ts @@ -0,0 +1,200 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Config, Context, Effect, Layer } from "effect" +import { + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import * as Http from "node:http" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { DEFAULT_TRANSCRIPTION_MODEL, MAX_TRANSCRIPTION_BYTES } from "../../src/redrob/transcription" +import { testEffect } from "../lib/effect" + +/** + * `POST /redrob/transcribe` through the production route tree, with a stand-in for the Redrob + * gateway behind it. What matters is that the app gets a transcript while the engine's key only ever + * goes to the gateway, that a request the gateway would refuse is refused here before anything is + * sent, and that every refusal arrives as the endpoint's declared error. + */ + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { disableListenLog: true, disableLogger: true }, +) +const it = testEffect( + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), +) + +/** A few bytes standing in for a webm/opus recording; the gateway stand-in never decodes them. */ +const CLIP = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42, 0x86, 0x81]).toString("base64") + +type Seen = { authorization: string | undefined; body: unknown } +type Answer = { status: number; body: string } + +/** The engine pointed at the stand-in and holding a Redrob key, restored when the test scope closes. */ +const engineEnv = (gatewayUrl: string) => + Effect.acquireRelease( + Effect.sync(() => { + const saved = { + url: process.env.REDROB_CONSOLE_URL, + auth: process.env.REDROB_AUTH_CONTENT, + key: process.env.REDROB_API_KEY, + } + process.env.REDROB_CONSOLE_URL = gatewayUrl + process.env.REDROB_AUTH_CONTENT = JSON.stringify({ redrob: { type: "api", key: "rk_engine_key" } }) + delete process.env.REDROB_API_KEY + return saved + }), + (saved) => + Effect.sync(() => { + for (const [name, value] of [ + ["REDROB_CONSOLE_URL", saved.url], + ["REDROB_AUTH_CONTENT", saved.auth], + ["REDROB_API_KEY", saved.key], + ] as const) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + }), + ) + +/** + * Topology: the engine's routes run on the test's primary server (NodeHttpServer.layerTest), and the + * Redrob gateway stand-in is a second listener built into this test's scope with its own fresh router, + * so its `/audio/transcriptions` cannot collide with anything the engine registers. The engine reaches + * the stand-in through `REDROB_CONSOLE_URL`, the developer seam the gateway URL already honours. + */ +const gateway = Effect.fn("TranscribeTest.gateway")(function* (answer: (body: Record) => Answer) { + const seen: Seen[] = [] + const context = yield* Layer.build( + Layer.mergeAll( + Layer.fresh(HttpRouter.layer), + NodeHttpServer.layer(() => Http.createServer(), { host: "127.0.0.1", port: 0 }), + ), + ) + const server = Context.get(context, HttpServer.HttpServer) + const router = Context.get(context, HttpRouter.HttpRouter) + yield* router.add( + "POST", + "/audio/transcriptions", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const raw = yield* request.json.pipe(Effect.orElseSucceed(() => ({}))) + const body = typeof raw === "object" && raw !== null ? Object.fromEntries(Object.entries(raw)) : {} + seen.push({ authorization: request.headers.authorization, body }) + const reply = answer(body) + return HttpServerResponse.text(reply.body, { status: reply.status, contentType: "application/json" }) + }), + ) + yield* server.serve(router.asHttpEffect()) + if (server.address._tag !== "TcpAddress") throw new Error("expected a TCP listener") + yield* engineEnv(`http://127.0.0.1:${server.address.port}`) + return { seen } +}) + +const transcript = (text: string) => ({ + status: 200, + body: JSON.stringify({ text, seconds: 3.2, requestId: "req_1", costUsd: 0.0004 }), +}) + +const transcribe = (payload: Record) => + HttpClientRequest.post(ControlPaths.transcribe).pipe( + HttpClientRequest.bodyJson(payload), + Effect.flatMap(HttpClient.execute), + ) + +describe("redrob transcribe HttpApi", () => { + it.live("answers with the gateway's transcript, sending the engine's key only to the gateway", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => transcript(" 회의 요약해 줘, and send it to Minji ")) + const response = yield* transcribe({ audio: CLIP, format: "webm", language: "KO" }) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + text: "회의 요약해 줘, and send it to Minji", + seconds: 3.2, + costUsd: 0.0004, + }) + expect(seen).toEqual([ + { + authorization: "Bearer rk_engine_key", + body: { + model: DEFAULT_TRANSCRIPTION_MODEL, + input_audio: { data: CLIP, format: "webm" }, + response_format: "json", + language: "ko", + }, + }, + ]) + }), + ) + + it.live("leaves the language to the gateway when none is given, and passes a chosen model through", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => transcript("Hello")) + const response = yield* transcribe({ audio: CLIP, format: "ogg", model: "openai/gpt-transcribe" }) + expect(response.status).toBe(200) + expect(seen[0]?.body).toEqual({ + model: "openai/gpt-transcribe", + input_audio: { data: CLIP, format: "ogg" }, + response_format: "json", + }) + }), + ) + + it.live("refuses audio the gateway would refuse with a 400, sending nothing", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => transcript("Hello")) + /* The smallest unpadded base64 that decodes to more than the limit. */ + const oversize = "A".repeat(Math.ceil((MAX_TRANSCRIPTION_BYTES + 1) / 3) * 4) + for (const payload of [ + { audio: " ", format: "webm" }, + { audio: `data:audio/webm;base64,${CLIP}`, format: "webm" }, + { audio: "abc", format: "webm" }, + { audio: oversize, format: "webm" }, + { audio: CLIP, format: "webm", language: "korean" }, + { audio: CLIP, format: "opus" }, + ]) { + expect((yield* transcribe(payload)).status).toBe(400) + } + expect(seen).toHaveLength(0) + }), + ) + + it.live("without a Redrob key it is a 503, sending nothing", () => + Effect.gen(function* () { + const { seen } = yield* gateway(() => transcript("Hello")) + process.env.REDROB_AUTH_CONTENT = JSON.stringify({}) + const response = yield* transcribe({ audio: CLIP, format: "webm" }) + expect(response.status).toBe(503) + expect(JSON.stringify(yield* response.json)).toContain("Connect Redrob") + expect(seen).toHaveLength(0) + }), + ) + + it.live("a gateway refusal, or an answer that is not a transcript, is a 502 with the gateway's message", () => + Effect.gen(function* () { + yield* gateway((body) => + body.model === "shapeless" + ? { status: 200, body: JSON.stringify({ transcript: "Hello" }) } + : { status: 402, body: JSON.stringify({ error: { message: "Out of credit." } }) }, + ) + const refused = yield* transcribe({ audio: CLIP, format: "webm" }) + expect(refused.status).toBe(502) + expect(JSON.stringify(yield* refused.json)).toContain("answered 402: Out of credit.") + const shapeless = yield* transcribe({ audio: CLIP, format: "webm", model: "shapeless" }) + expect(shapeless.status).toBe(502) + expect(JSON.stringify(yield* shapeless.json)).toContain("without a transcript") + }), + ) +}) diff --git a/packages/redrob/test/session/system.test.ts b/packages/redrob/test/session/system.test.ts index 2ad97be86a..3a112bc3e0 100644 --- a/packages/redrob/test/session/system.test.ts +++ b/packages/redrob/test/session/system.test.ts @@ -35,6 +35,32 @@ const skills: Skill.Info[] = [ location: "/tmp/manual-skill/SKILL.md", content: "# manual-skill", }, + // Two armable skills, because one proves too little: with a single candidate a wiring bug + // that armed EVERY skill and one that armed the right skill look identical. + { + name: "docs-skill", + description: "Docs skill.", + location: "/tmp/docs-skill/SKILL.md", + content: "# docs-skill\nHow to edit a document.", + autoInject: { keywords: ["spreadsheet", "pptx"], url: ["docs.google.com/**"] }, + }, + { + name: "deploy-skill", + description: "Deploy skill.", + location: "/tmp/deploy-skill/SKILL.md", + content: "# deploy-skill\nHow to roll back.", + autoInject: { keywords: ["rollback"] }, + }, + // A keyword carrying the characters that break an attribute. Skill files are author + // supplied, so this is reachable, and the first version of the armed-by attribute put the + // raw quote straight into the tag. + { + name: "quoted-skill", + description: "Quoted skill.", + location: "/tmp/quoted-skill/SKILL.md", + content: "# quoted-skill\nBody of the quoted skill.", + autoInject: { keywords: ['say "hello" '] }, + }, ] const build: Agent.Info = { @@ -159,8 +185,53 @@ describe("session.system", () => { }), ) - it.effect("MCP output includes connected server instructions", () => + it.effect("a keyword in the prompt arms that skill's body and no other", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.armedSkills(build, { prompt: "export this as a spreadsheet please" }) + const armed = output ?? (yield* Effect.fail(new NamedError.Unknown({ message: "nothing armed" }))) + + // The BODY, not the description: an armed skill is one the model is already reading. + expect(armed).toContain("How to edit a document.") + expect(armed).toContain('armed-by="keyword spreadsheet"') + // The other armable skill did not match, and must not ride along. + expect(armed).not.toContain("How to roll back.") + // A skill with no autoInject block never arms, however the prompt reads. + expect(armed).not.toContain("manual-skill") + }), + ) + + it.effect("a prompt matching nothing arms nothing at all", () => Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + // Mentions skills and documents in the abstract, matching no declared keyword. + expect(yield* prompt.armedSkills(build, { prompt: "what skills do you have?" })).toBeUndefined() + }), + ) + + it.effect("arming is denied with the skill permission, like the discovery list", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const denied: Agent.Info = { ...build, permission: Permission.fromConfig({ skill: "deny" }) } + expect(yield* prompt.armedSkills(denied, { prompt: "export this as a spreadsheet" })).toBeUndefined() + }), + ) + + it.effect("a pattern with markup characters is escaped, not emitted raw", () => + Effect.gen(function* () { + const prompt = yield* SystemPrompt.Service + const output = yield* prompt.armedSkills(build, { prompt: 'please say "hello" ' }) + const armed = output ?? (yield* Effect.fail(new NamedError.Unknown({ message: "nothing armed" }))) + + expect(armed).toContain("Body of the quoted skill.") + expect(armed).toContain("armed-by=\"keyword say "hello" <now>\"") + // The tag must close where it is supposed to: one `">` on the opening line. + const opening = armed.split("\n").find((line) => line.includes("quoted-skill")) ?? "" + expect(opening.endsWith('">')).toBe(true) + }), + ) + + it.effect("MCP output includes connected server instructions", () => Effect.gen(function* () { const prompt = yield* SystemPrompt.Service const output = yield* prompt.mcp(build) diff --git a/packages/redrob/test/skill/skill.test.ts b/packages/redrob/test/skill/skill.test.ts index 352af217bd..baa7f08c5a 100644 --- a/packages/redrob/test/skill/skill.test.ts +++ b/packages/redrob/test/skill/skill.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { LayerNode } from "@redrob-code/core/effect/layer-node" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" +import { SkillArming } from "@redrob-code/core/skill/arming" import { Discovery } from "../../src/skill/discovery" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -320,6 +321,97 @@ description: A skill in the .claude/skills directory. ), ) + it.live("carries a disk skill's autoInject block through to the arming matcher", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".redrob", "skill", "armed-skill", "SKILL.md"), + `--- +name: armed-skill +description: A skill that declares its own arming. +autoInject: + keywords: + - rollback + - "incident report" + url: + - status.example.com/** +--- + +# Armed Skill + +Instructions here. +`, + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".redrob", "skill", "plain-skill", "SKILL.md"), + `--- +name: plain-skill +description: A skill with no arming block. +--- + +# Plain Skill +`, + ), + ) + + const skill = yield* Skill.Service + const list = (yield* skill.all()).filter((s) => s.location !== "") + const armable = list.find((x) => x.name === "armed-skill")! + const plain = list.find((x) => x.name === "plain-skill")! + + // The loader used to drop this field entirely, which left K-2's matcher with + // nothing to match on: every skill looked like `plain-skill`. + expect(armable.autoInject?.keywords).toEqual(["rollback", "incident report"]) + expect(armable.autoInject?.url).toEqual(["status.example.com/**"]) + expect(plain.autoInject).toBeUndefined() + + // End to end through the real matcher, on the real loaded values. + expect(SkillArming.armedNames({ skills: list, prompt: "time to ROLLBACK" })).toEqual(["armed-skill"]) + expect( + SkillArming.armedNames({ skills: list, prompt: "nothing here", url: "status.example.com/incidents/4" }), + ).toEqual(["armed-skill"]) + expect(SkillArming.armedNames({ skills: list, prompt: "nothing here" })).toEqual([]) + }), + { git: true }, + ), + ) + + it.live("keeps a skill whose autoInject block is malformed, minus the arming", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".redrob", "skill", "broken-skill", "SKILL.md"), + `--- +name: broken-skill +description: Arming block in a shape the schema rejects. +autoInject: "rollback" +--- + +# Broken Skill +`, + ), + ) + + const skill = yield* Skill.Service + const item = (yield* skill.all()).find((x) => x.name === "broken-skill") + + // The documented failure mode: the skill still loads and stays selectable by + // name, and only the arming behaviour is lost. Dropping the whole file would + // lose a working skill over an optional key. + expect(item).toBeDefined() + expect(item!.content).toContain("Broken Skill") + expect(item!.autoInject).toBeUndefined() + }), + { git: true }, + ), + ) + it.effect("exposes tagged expected skill failure classes", () => Effect.sync(() => { const invalid = new Skill.InvalidError({ path: "/tmp/SKILL.md", message: "Invalid skill frontmatter" }) diff --git a/packages/redrob/test/tool/browser-channel.ts b/packages/redrob/test/tool/browser-channel.ts new file mode 100644 index 0000000000..efa2145ce0 --- /dev/null +++ b/packages/redrob/test/tool/browser-channel.ts @@ -0,0 +1,27 @@ +import { BrowserRequestV1 } from "@redrob-code/core/browser-request" +import { Effect, Layer } from "effect" + +/** + * PA-10 test double for the browser channel. + * + * Shared because four suites build the real code-mode tool, and that tool now resolves + * `page` through this service. Without it they fail to construct rather than failing an + * assertion, which is a worse signal. + * + * `ask` fails immediately instead of sleeping to the service's real deadline. The deadline + * is tested where it lives (`packages/core/test/browser-request.test.ts`); paying 20 seconds + * for it in every suite that merely needs the tool to exist would buy nothing. + */ +export function unansweredBrowser(): BrowserRequestV1.Interface { + return { + ask: (input) => Effect.fail(new BrowserRequestV1.UnansweredError({ action: input.command.action })), + reply: () => Effect.void, + refuse: () => Effect.void, + list: () => Effect.succeed([]), + } +} + +/** The layer form, for a harness that just needs the service present. */ +export function unansweredBrowserLayer() { + return Layer.mock(BrowserRequestV1.Service, unansweredBrowser()) +} diff --git a/packages/redrob/test/tool/code-mode-integration.test.ts b/packages/redrob/test/tool/code-mode-integration.test.ts index 671acd8962..154911d627 100644 --- a/packages/redrob/test/tool/code-mode-integration.test.ts +++ b/packages/redrob/test/tool/code-mode-integration.test.ts @@ -18,6 +18,7 @@ import { type Tool as MCPToolDef, } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit, Layer } from "effect" +import { unansweredBrowserLayer } from "./browser-channel" const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" @@ -152,6 +153,7 @@ async function buildTool() { tools: () => Effect.succeed(mcpTools), clients: () => Effect.succeed({ [SERVER]: {} as any }), }), + unansweredBrowserLayer(), ) return { tool: await Effect.runPromise(CodeModeTool.pipe(Effect.flatMap(Tool.init), Effect.provide(layer))), diff --git a/packages/redrob/test/tool/code-mode.test.ts b/packages/redrob/test/tool/code-mode.test.ts index 16cc08bbec..2fe90a6cd6 100644 --- a/packages/redrob/test/tool/code-mode.test.ts +++ b/packages/redrob/test/tool/code-mode.test.ts @@ -11,6 +11,7 @@ import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" import { MessageID, SessionID } from "@/session/schema" import { Cause, Effect, Exit, Layer, Schema } from "effect" +import { unansweredBrowserLayer } from "./browser-channel" const ctx: Tool.Context = { sessionID: SessionID.make("ses_code-mode"), @@ -60,6 +61,7 @@ function harness(input: { tools: () => Effect.succeed(input.mcpTools), clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))), }), + unansweredBrowserLayer(), ) } @@ -256,7 +258,7 @@ describe("code mode execute", () => { expect(output.metadata.toolCalls).toEqual([]) }) - test("Object.keys(tools) enumerates the MCP server and CodeMode namespaces", async () => { + test("Object.keys(tools) enumerates the MCP server, domain, and CodeMode namespaces", async () => { const tool = await build({ github_list_issues: mcpTool("list_issues", () => ""), linear_search: mcpTool("search", () => ""), @@ -267,7 +269,13 @@ describe("code mode execute", () => { ctx, ), ) - expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear", "$codemode"], count: 3 }) + // The K-1 domain namespaces (`channel`, `page`) and K-3's document namespaces (`docx`, + // `pdf`, `pptx`, `xlsx`) are part of the language, so they are always present alongside + // whatever MCP servers are connected. + expect(JSON.parse(output.output)).toEqual({ + namespaces: ["github", "linear", "channel", "page", "docx", "pdf", "pptx", "xlsx", "$codemode"], + count: 9, + }) }) test("calls a namespaced MCP tool and flows its text result back into the program", async () => { diff --git a/packages/redrob/test/tool/document-skill.test.ts b/packages/redrob/test/tool/document-skill.test.ts new file mode 100644 index 0000000000..5afee604c5 --- /dev/null +++ b/packages/redrob/test/tool/document-skill.test.ts @@ -0,0 +1,101 @@ +/** + * K-3's skill document, held to the code it describes. + * + * A skill is only cheap because the objects exist; a skill that describes a method that does + * not exist produces an agent that calls it and fails. So this test reads the document the + * engine actually ships — `SkillPlugin.DocumentToolchainContent` — and checks it BOTH ways: + * every `.` the document names is a real tool, and every real tool is named by + * the document. The field names it promises are compared against the keys real calls return, + * not against a list retyped here. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import { Effect } from "effect" +import { SkillPlugin } from "@redrob-code/core/plugin/skill" +import { DOCUMENT_GLOBALS, documentTools, documents, unavailableDocuments } from "@/tool/document" + +const DOC = SkillPlugin.DocumentToolchainContent + +let directory: string +beforeAll(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "redrob-k3-skill-")) +}) +afterAll(async () => { + await fs.rm(directory, { recursive: true, force: true }) +}) + +const run = (effect: Effect.Effect) => Effect.runPromise(effect) + +/** Every `.` the document mentions, deduplicated. */ +const mentioned = new Set( + [...DOC.matchAll(/\b(docx|xlsx|pptx|pdf)\.([A-Za-z]+)\b/g)].map((match) => `${match[1]}.${match[2]}`), +) + +/** Every `.` the code actually binds. */ +const real = new Set( + Object.entries(documentTools(unavailableDocuments())).flatMap(([global, namespace]) => + Object.keys(namespace).map((method) => `${global}.${method}`), + ), +) + +describe("the K-3 skill document describes the code that exists", () => { + test("it names every global the engine binds", () => { + for (const name of DOCUMENT_GLOBALS) expect(DOC).toContain(`\`${name}\``) + }) + + test("every method the document names is a real tool", () => { + expect([...mentioned].filter((entry) => !real.has(entry))).toStrictEqual([]) + }) + + test("every real tool is documented, so nothing ships undescribed", () => { + expect([...real].filter((entry) => !mentioned.has(entry))).toStrictEqual([]) + }) + + test("the result fields it promises are the fields a real call returns", async () => { + const docs = documents({ directory }) + + const docxWrite = await run(docs.docx.write({ path: path.join(directory, "a.docx"), paragraphs: [{ text: "x" }] })) + for (const key of Object.keys(docxWrite)) expect(DOC).toContain(key) + + const docxRead = await run(docs.docx.read({ path: path.join(directory, "a.docx") })) + for (const key of Object.keys(docxRead)) expect(DOC).toContain(key) + + await run( + docs.xlsx.write({ path: path.join(directory, "a.xlsx"), sheets: [{ name: "S", rows: [["h"]] }] }), + ) + const xlsxRead = await run(docs.xlsx.read({ path: path.join(directory, "a.xlsx") })) + for (const key of Object.keys(xlsxRead)) expect(DOC).toContain(key) + for (const key of Object.keys(xlsxRead.sheets[0]!)) expect(DOC).toContain(key) + + await run(docs.pptx.write({ path: path.join(directory, "a.pptx"), slides: [{ title: "T" }] })) + const pptxRead = await run(docs.pptx.read({ path: path.join(directory, "a.pptx") })) + for (const key of Object.keys(pptxRead)) expect(DOC).toContain(key) + for (const key of Object.keys(pptxRead.slides[0]!)) expect(DOC).toContain(key) + + await run(docs.pdf.write({ path: path.join(directory, "a.pdf"), title: "T", pages: [{ lines: ["l"] }] })) + const pdfRead = await run(docs.pdf.read({ path: path.join(directory, "a.pdf") })) + for (const key of Object.keys(pdfRead)) expect(DOC).toContain(key) + for (const key of Object.keys(pdfRead.pages[0]!)) expect(DOC).toContain(key) + // `reason` only appears on an incomplete page, so it is asserted from one. + const blank = await run(docs.pdf.write({ path: path.join(directory, "b.pdf"), pages: [{ lines: [] }] })) + expect(blank.bytes).toBeGreaterThan(0) + const blankRead = await run(docs.pdf.read({ path: path.join(directory, "b.pdf") })) + for (const key of Object.keys(blankRead.pages[0]!)) expect(DOC).toContain(key) + }) + + test("it states the limit that makes pdf.read honest, and does not promise OCR", () => { + expect(DOC).toContain("textComplete") + expect(DOC).toContain("Type0") + expect(DOC).toContain("there is no OCR in the engine") + expect(DOC).not.toContain("Aside") + }) + + test("the registered skill's own description and arming match the document it carries", async () => { + // The skill is registered as an embedded source in packages/core; this asserts the body + // the plugin hands over is this document and not a stale copy. + expect(DOC).toContain("# Reading and writing documents in code mode") + expect(DOC.startsWith("