diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..4eaa6999 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,27 @@ +You are an expert programmer in Rust teaching who is teaching another developer who is learning Rust. +The students are familiar with programming in languages such as Python (advanced), Java (novice) and C (novice) so +when possible use analogies from those languages. + +Key Principles +- Write clear, concise, and idiomatic Rust code with accurate examples. +- Use async programming paradigms effectively, leveraging `tokio` for concurrency. +- Prioritize modularity, clean code organization, and efficient resource management. +- Use expressive variable names that convey intent (e.g., `is_ready`, `has_data`). +- Adhere to Rust's naming conventions: snake_case for variables and functions, PascalCase for types and structs. +- Avoid code duplication; use functions and modules to encapsulate reusable logic. +- Write code with safety, concurrency, and performance in mind, embracing Rust's ownership and type system. + +Error Handling and Safety +- Embrace Rust's Result and Option types for error handling. +- Use `?` operator to propagate errors in async functions. +- Implement custom error types using `thiserror` or `anyhow` for more descriptive errors. +- Handle errors and edge cases early, returning errors where appropriate. +- Use `.await` responsibly, ensuring safe points for context switching. + +Key Conventions +1. Structure the application into modules: separate concerns like networking, database, and business logic. +2. Use environment variables for configuration management (e.g., `dotenv` crate). +3. Ensure code is well-documented with inline comments and Rustdoc. +4. Do not use the older style of "MOD/mod.rs" for separing modules and instead use the "MOD.rs" filename convention. + +Refer to "The Rust Programming Language" book (2024 version) and "Command line apps in Rust" documentation for in-depth information on best practices, and advanced features. diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml new file mode 100644 index 00000000..9f812d95 --- /dev/null +++ b/.github/workflows/build-cli.yml @@ -0,0 +1,69 @@ +# This is a **reuseable** workflow that bundles the Desktop App for macOS. +# It doesn't get triggered on its own. It gets used in multiple workflows: +# - release.yml +# - canary.yml +on: + workflow_call: + inputs: + # Let's allow overriding the OSes and architectures in JSON array form: + # e.g. '["ubuntu-latest","macos-latest"]' + # If no input is provided, these defaults apply. + operating-systems: + type: string + required: false + default: '["ubuntu-latest","macos-latest"]' + architectures: + type: string + required: false + default: '["x86_64","aarch64"]' + +name: "Reusable workflow to build CLI" + +jobs: + build-cli: + name: Build CLI + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJson(inputs.operating-systems) }} + architecture: ${{ fromJson(inputs.architectures) }} + include: + - os: ubuntu-latest + target-suffix: unknown-linux-gnu + - os: macos-latest + target-suffix: apple-darwin + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + target: ${{ matrix.architecture }}-${{ matrix.target-suffix }} + + - name: Install cross + run: cargo install cross --git https://github.com/cross-rs/cross + + - name: Build CLI + env: + CROSS_NO_WARNINGS: 0 + run: | + export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" + rustup target add "${TARGET}" + + # 'cross' is used to cross-compile for different architectures (see Cross.toml) + cross build --release --target ${TARGET} -p goose-cli + + # tar the goose binary as goose-.tar.bz2 + cd target/${TARGET}/release + tar -cjf goose-${TARGET}.tar.bz2 goose + echo "ARTIFACT=target/${TARGET}/release/goose-${TARGET}.tar.bz2" >> $GITHUB_ENV + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: goose-${{ matrix.architecture }}-${{ matrix.target-suffix }} + path: ${{ env.ARTIFACT }} diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml new file mode 100644 index 00000000..87152d94 --- /dev/null +++ b/.github/workflows/bundle-desktop.yml @@ -0,0 +1,198 @@ +# This is a **reuseable** workflow that bundles the Desktop App for macOS. +# It doesn't get triggered on its own. It gets used in multiple workflows: +# - release.yml +# - canary.yml +# - pr-comment-bundle-desktop.yml +on: + workflow_call: + inputs: + signing: + description: 'Whether to perform signing and notarization' + required: false + default: false + type: boolean + secrets: + CERTIFICATE_OSX_APPLICATION: + description: 'Certificate for macOS application signing' + required: false + CERTIFICATE_PASSWORD: + description: 'Password for the macOS certificate' + required: false + APPLE_ID: + description: 'Apple ID for notarization' + required: false + APPLE_ID_PASSWORD: + description: 'Password for the Apple ID' + required: false + APPLE_TEAM_ID: + description: 'Apple Team ID' + required: false + +name: Reusable workflow to bundle desktop app + +jobs: + bundle-desktop: + runs-on: macos-latest + name: Bundle Desktop App on macOS + steps: + # Validate Signing Secrets if signing is enabled + - name: Validate Signing Secrets + if: ${{ inputs.signing }} + run: | + if [[ -z "${{ secrets.CERTIFICATE_OSX_APPLICATION }}" ]]; then + echo "Error: CERTIFICATE_OSX_APPLICATION secret is required for signing." + exit 1 + fi + if [[ -z "${{ secrets.CERTIFICATE_PASSWORD }}" ]]; then + echo "Error: CERTIFICATE_PASSWORD secret is required for signing." + exit 1 + fi + if [[ -z "${{ secrets.APPLE_ID }}" ]]; then + echo "Error: APPLE_ID secret is required for signing." + exit 1 + fi + if [[ -z "${{ secrets.APPLE_ID_PASSWORD }}" ]]; then + echo "Error: APPLE_ID_PASSWORD secret is required for signing." + exit 1 + fi + if [[ -z "${{ secrets.APPLE_TEAM_ID }}" ]]; then + echo "Error: APPLE_TEAM_ID secret is required for signing." + exit 1 + fi + echo "All required signing secrets are present." + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Cache Cargo registry + uses: actions/cache@v3 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache Cargo index + uses: actions/cache@v3 + with: + path: ~/.cargo/index + key: ${{ runner.os }}-cargo-index + restore-keys: | + ${{ runner.os }}-cargo-index + + - name: Cache Cargo build + uses: actions/cache@v3 + with: + path: target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + + - name: Build goosed + run: cargo build --release -p goose-server + + - name: Copy binary into Electron folder + run: cp target/release/goosed ui/desktop/src/bin/goosed + + # Conditional Signing Step - we skip this for faster builds + - name: Add MacOS certs for signing and notarization + if: ${{ inputs.signing }} + run: ./add-macos-cert.sh + working-directory: ui/desktop + env: + CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} + CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: 'lts/*' + + - name: Install dependencies + run: npm ci + working-directory: ui/desktop + + - name: Make Unsigned App + if: ${{ !inputs.signing }} + run: | + attempt=0 + max_attempts=2 + until [ $attempt -ge $max_attempts ]; do + npm run bundle:default && break + attempt=$((attempt + 1)) + echo "Attempt $attempt failed. Retrying..." + sleep 5 + done + if [ $attempt -ge $max_attempts ]; then + echo "Action failed after $max_attempts attempts." + exit 1 + fi + working-directory: ui/desktop + + - name: Make Signed App + if: ${{ inputs.signing }} + run: | + attempt=0 + max_attempts=2 + until [ $attempt -ge $max_attempts ]; do + npm run bundle:default && break + attempt=$((attempt + 1)) + echo "Attempt $attempt failed. Retrying..." + sleep 5 + done + if [ $attempt -ge $max_attempts ]; then + echo "Action failed after $max_attempts attempts." + exit 1 + fi + working-directory: ui/desktop + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Upload Desktop artifact + uses: actions/upload-artifact@v4 + with: + name: Goose-darwin-arm64 + path: ui/desktop/out/Goose-darwin-arm64/Goose.zip + + - name: Quick launch test (macOS) + run: | + # Ensure no quarantine attributes (if needed) + xattr -cr "ui/desktop/out/Goose-darwin-arm64/Goose.app" + echo "Opening Goose.app..." + open -g "ui/desktop/out/Goose-darwin-arm64/Goose.app" + + # Give the app a few seconds to start and write logs + sleep 5 + + # Check if it's running + if pgrep -f "Goose.app/Contents/MacOS/Goose" > /dev/null; then + echo "App appears to be running." + else + echo "App did not stay open. Possible crash or startup error." + exit 1 + fi + LOGFILE="$HOME/Library/Application Support/Goose/logs/main.log" + # Print the log and verify "ChatWindow loaded" is in the logs + if [ -f "$LOGFILE" ]; then + echo "===== Log file contents =====" + cat "$LOGFILE" + echo "=============================" + if grep -F "ChatWindow loaded" "$LOGFILE"; then + echo "Confirmed: 'ChatWindow loaded' found in logs!" + else + echo "Did not find 'ChatWindow loaded' in logs. Failing..." + exit 1 + fi + else + echo "No log file found at $LOGFILE. Exiting with failure." + exit 1 + fi + # Kill the app to clean up + pkill -f "Goose.app/Contents/MacOS/Goose" diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml new file mode 100644 index 00000000..9ce651b3 --- /dev/null +++ b/.github/workflows/canary.yml @@ -0,0 +1,82 @@ +# This workflow is for canary releases, automatically triggered by push to v1.0 branch. +# This workflow is identical to "release.yml" with these exceptions: +# - Triggered by push to v1.0 branch +# - Github Release tagged as "canary" +on: + push: + paths-ignore: + - 'docs/**' + branches: + - main + +name: Canary + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ------------------------------------ + # 1) Build CLI for multiple OS/Arch + # ------------------------------------ + build-cli: + uses: ./.github/workflows/build-cli.yml + + # ------------------------------------ + # 2) Upload Install CLI Script (we only need to do this once) + # ------------------------------------ + install-script: + name: Upload Install Script + runs-on: ubuntu-latest + needs: [ build-cli ] + steps: + - uses: actions/checkout@v4 + - uses: actions/upload-artifact@v4 + with: + name: download_cli.sh + path: download_cli.sh + + # ------------------------------------------------------------ + # 3) Bundle Desktop App (macOS only) - builds goosed and Electron app + # ------------------------------------------------------------ + bundle-desktop: + uses: ./.github/workflows/bundle-desktop.yml + with: + signing: true + secrets: + CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} + CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # ------------------------------------ + # 4) Create/Update GitHub Release + # ------------------------------------ + release: + name: Release + runs-on: ubuntu-latest + needs: [ build-cli, install-script, bundle-desktop ] + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + merge-multiple: true + + # Create/update the canary release + - name: Release canary + uses: ncipollo/release-action@v1 + with: + tag: canary + name: Canary + token: ${{ secrets.GITHUB_TOKEN }} + artifacts: | + goose-*.tar.bz2 + Goose*.zip + download_cli.sh + allowUpdates: true + omitBody: true + omitPrereleaseDuringUpdate: true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 28a2a3a7..00000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,111 +0,0 @@ -name: CI - -on: - pull_request: - branches: - - main # Trigger CI on PRs to main - - push: - branches: - - main # Trigger CI on pushes to main - -jobs: - exchange: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - version: "0.5.4" - - - name: Source Cargo Environment - run: source $HOME/.cargo/env - - - name: Ruff - run: | - uvx ruff check packages/exchange - uvx ruff format packages/exchange --check - - - name: Run tests - working-directory: ./packages/exchange - run: | - uv run pytest tests -m 'not integration' - - goose: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - version: "0.5.4" - - - name: Source Cargo Environment - run: source $HOME/.cargo/env - - - name: Ruff - run: | - uvx ruff check src tests - uvx ruff format src tests --check - - - name: Run tests - run: | - uv run pytest tests -m 'not integration' - - - # This runs integration tests of the OpenAI API, using Ollama to host models. - # This lets us test PRs from forks which can't access secrets like API keys. - ollama: - runs-on: ubuntu-latest - - strategy: - matrix: - python-version: - # Only test the lastest python version. - - "3.12" - ollama-model: - # For quicker CI, use a smaller, tool-capable model than the default. - - "qwen2.5:0.5b" - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - version: "0.5.4" - - - name: Source Cargo Environment - run: source $HOME/.cargo/env - - - name: Set up Python - run: uv python install ${{ matrix.python-version }} - - - name: Install Ollama - run: curl -fsSL https://ollama.com/install.sh | sh - - - name: Start Ollama - run: | - # Run the background, in a way that survives to the next step - nohup ollama serve > ollama.log 2>&1 & - - # Block using the ready endpoint - time curl --retry 5 --retry-connrefused --retry-delay 1 -sf http://localhost:11434 - - # Tests use OpenAI which does not have a mechanism to pull models. Run a - # simple prompt to (pull and) test the model first. - - name: Test Ollama model - run: ollama run $OLLAMA_MODEL hello || cat ollama.log - env: - OLLAMA_MODEL: ${{ matrix.ollama-model }} - - - name: Run Ollama tests - run: uv run pytest tests -m integration -k ollama - working-directory: ./packages/exchange - env: - OLLAMA_MODEL: ${{ matrix.ollama-model }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..8411f17c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +on: + push: + paths-ignore: + - 'docs/**' + branches: + - main + pull_request: + paths-ignore: + - 'docs/**' + branches: + - main + workflow_dispatch: + +name: CI + +jobs: + rust-format: + name: Check Rust Code Format + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Run cargo fmt + run: cargo fmt --check + + rust-build-and-test: + name: Build and Test Rust Project + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Dependencies + run: | + sudo apt update -y + sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Cache Cargo Registry + uses: actions/cache@v3 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache Cargo Index + uses: actions/cache@v3 + with: + path: ~/.cargo/index + key: ${{ runner.os }}-cargo-index + restore-keys: | + ${{ runner.os }}-cargo-index + + - name: Cache Cargo Build + uses: actions/cache@v3 + with: + path: target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + + - name: Build and Test + run: | + gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar' + cargo test + working-directory: crates + + - name: Lint + run: cargo clippy -- -D warnings + + desktop-lint: + name: Lint Electron Desktop App + runs-on: macos-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: 'lts/*' + + - name: Install Dependencies + run: npm ci + working-directory: ui/desktop + + - name: Run Lint + run: npm run lint:check + working-directory: ui/desktop + + # Faster Desktop App build for PRs only + bundle-desktop-unsigned: + uses: ./.github/workflows/bundle-desktop.yml + if: github.event_name == 'pull_request' + with: + signing: false diff --git a/.github/workflows/deploy-docs-and-extensions.yml b/.github/workflows/deploy-docs-and-extensions.yml new file mode 100644 index 00000000..752d5d9c --- /dev/null +++ b/.github/workflows/deploy-docs-and-extensions.yml @@ -0,0 +1,47 @@ +name: Deploy v1 Docs & Extensions # (/documentation and /extensions-site) + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout the branch + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Install dependencies and build docs + working-directory: ./documentation + run: | + npm install + npm run build + + - name: Install dependencies and build extensions-site + working-directory: ./extensions-site + env: + VITE_BASENAME: "/goose/v1/extensions/" # Set the base URL here for the extensions site + run: | + npm install + npm run build + + - name: Combine builds into one directory + run: | + mkdir combined-build + cp -r documentation/build/* combined-build/ + mkdir -p combined-build/extensions + cp -r extensions-site/build/client/* combined-build/extensions/ + + - name: Deploy to gh-pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: combined-build + destination_dir: v1 # Deploy the site to the 'v1' subfolder diff --git a/.github/workflows/deploy_docs.yaml b/.github/workflows/deploy_docs.yaml deleted file mode 100644 index d1977123..00000000 --- a/.github/workflows/deploy_docs.yaml +++ /dev/null @@ -1,40 +0,0 @@ -name: Deploy MkDocs - -on: - push: - branches: - - main # Trigger deployment on pushes to main - - paths: - - 'docs/**' - - 'mkdocs.yml' - - '.github/workflows/deploy_docs.yaml' - - pull_request: - branches: - - main - paths: - - 'docs/**' - - 'mkdocs.yml' - - '.github/workflows/deploy_docs.yaml' - - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install UV - uses: astral-sh/setup-uv@v3 - - - name: Create UV virtual environment - run: uv venv - - - name: Install dependencies - run: uv pip install "mkdocs-material[imaging]" Pillow cairosvg - - - name: Build the documentation - run: uv run mkdocs gh-deploy --force diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml deleted file mode 100644 index fb1429e9..00000000 --- a/.github/workflows/license-check.yml +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: License Check - -on: - pull_request: # Trigger license check on any PRs - paths: - - '**/pyproject.toml' - - '.github/workflows/license-check.yml' - - '.github/workflows/scripts/check_licenses.py' - - push: # Trigger license check on pushes to main - branches: - - main - paths: # TODO: can't DRY unless https://github.com/actions/runner/issues/1182 - - '**/pyproject.toml' - - '.github/workflows/license-check.yml' - - '.github/workflows/scripts/check_licenses.py' - -jobs: - check-licenses: - name: Check Package Licenses - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tomli requests urllib3 - - - name: Check licenses - run: | - python .github/workflows/scripts/check_licenses.py \ - pyproject.toml || exit_code=$? - if [ "${exit_code:-0}" -ne 0 ]; then - echo "::error::Found packages with disallowed licenses" - exit 1 - fi - - - name: Check Exchange licenses - run: | - python .github/workflows/scripts/check_licenses.py \ - packages/exchange/pyproject.toml || exit_code=$? - if [ "${exit_code:-0}" -ne 0 ]; then - echo "::error::Found packages with disallowed licenses in exchange" - exit 1 - fi diff --git a/.github/workflows/pr-comment-bundle-desktop.yml b/.github/workflows/pr-comment-bundle-desktop.yml new file mode 100644 index 00000000..a0e143d2 --- /dev/null +++ b/.github/workflows/pr-comment-bundle-desktop.yml @@ -0,0 +1,76 @@ +# This workflow is triggered by a comment on an issue or PR with the text ".bundle" +# It bundles the Desktop App, then creates a PR comment with a link to download the app. + +# IMPORTANT: issue_comment workflows only use files found on "main" branch to run. +# Do NOT allow on: pull_request since that allows a user to alter a file in a PR and exfil secrets for example. +# Only using issue_comment is the suggested workflow type. Comments on pull requests, and issues will trigger the issue_comment workflow event. +on: + issue_comment: + types: [created] + +# permissions needed for reacting to IssueOps commands on PRs +permissions: + pull-requests: write + checks: read + # issues: write + +name: Workflow to Bundle Desktop App + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + trigger-on-command: + name: Trigger on ".bundle" PR comment + runs-on: ubuntu-latest + steps: + - uses: github/command@v1.3.0 + id: command + with: + command: ".bundle" + reaction: "eyes" + allowed_contexts: pull_request + + bundle-desktop: + # Only run this if ".bundle" command is detected. + if: ${{ steps.command.outputs.continue == 'true' }} + uses: ./.github/workflows/bundle-desktop.yml + with: + signing: true + secrets: + CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} + CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + pr-comment: + name: PR Comment with Desktop App + runs-on: ubuntu-latest + needs: [ bundle-desktop ] + permissions: + pull-requests: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + merge-multiple: true + + - name: Comment on PR with download link + uses: peter-evans/create-or-update-comment@v3 + with: + comment-id: ${{ steps.command.outputs.comment_id }} + issue-number: ${{ github.event.pull_request.number }} + body: | + ### Desktop App for this PR + + The following build is available for testing: + + - [πŸ“± macOS Desktop App (arm64, signed)](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/Goose-darwin-arm64.zip) + + After downloading, unzip the file and drag the Goose.app to your Applications folder. The app is signed and notarized for macOS. + + This link is provided by nightly.link and will work even if you're not logged into GitHub. + edit-mode: replace diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml deleted file mode 100644 index 969ebb7e..00000000 --- a/.github/workflows/publish.yaml +++ /dev/null @@ -1,50 +0,0 @@ -name: Publish - -# A release on goose will also publish exchange, if it has updated -# This means in some cases we may need to make a bump in goose without other changes to release exchange -on: - release: - types: [published] - -jobs: - publish: - permissions: - id-token: write - contents: read - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Get current version from pyproject.toml - id: get_version - run: | - echo "VERSION=$(grep -m 1 'version =' "pyproject.toml" | awk -F'"' '{print $2}')" >> $GITHUB_ENV - - - name: Extract tag version - id: extract_tag - run: | - TAG_VERSION=$(echo "${{ github.event.release.tag_name }}" | sed -E 's/v(.*)/\1/') - echo "TAG_VERSION=$TAG_VERSION" >> $GITHUB_ENV - - - name: Check if tag matches version from pyproject.toml - id: check_tag - run: | - if [ "${{ env.TAG_VERSION }}" != "${{ env.VERSION }}" ]; then - echo "::error::Tag version (${{ env.TAG_VERSION }}) does not match version in pyproject.toml (${{ env.VERSION }})." - exit 1 - fi - - - name: Install the latest version of uv - uses: astral-sh/setup-uv@v1 - with: - version: "latest" - - - name: Build Package - run: | - uv build -o dist --package goose-ai - uv build -o dist --package ai-exchange - - - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - skip-existing: true diff --git a/.github/workflows/pull_request_title.yaml b/.github/workflows/pull_request_title.yaml deleted file mode 100644 index 8a32172a..00000000 --- a/.github/workflows/pull_request_title.yaml +++ /dev/null @@ -1,48 +0,0 @@ -name: 'Lint PR' - -on: - pull_request_target: - types: - - opened - - edited - - synchronize - - reopened - -permissions: - pull-requests: write - -jobs: - main: - name: Validate PR title - runs-on: ubuntu-latest - steps: - - uses: amannn/action-semantic-pull-request@v5 - id: lint_pr_title - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - requireScope: false - - - uses: marocchino/sticky-pull-request-comment@v2 - # When the previous steps fails, the workflow would stop. By adding this - # condition you can continue the execution with the populated error message. - if: always() && (steps.lint_pr_title.outputs.error_message != null) - with: - header: pr-title-lint-error - message: | - Hey there and thank you for opening this pull request! πŸ‘‹πŸΌ - - We require pull request titles to follow the [Conventional Commits specification](https://gist.github.com/Zekfad/f51cb06ac76e2457f11c80ed705c95a3#file-conventional-commits-md) and it looks like your proposed title needs to be adjusted. - - Details: - - ``` - ${{ steps.lint_pr_title.outputs.error_message }} - ``` - - # Delete a previous comment when the issue has been resolved - - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: pr-title-lint-error - delete: true diff --git a/.github/workflows/release-monitor.yml b/.github/workflows/release-monitor.yml deleted file mode 100644 index 46883550..00000000 --- a/.github/workflows/release-monitor.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Release Monitor - -on: - release: - types: [published] - workflow_dispatch: # Add this line to enable manual triggering - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pipx - pipx install goose-ai - - - name: Check Goose AI Version - run: goose version - - - name: Create Issue on Failure - if: failure() - uses: actions/github-script@v3 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { owner, repo } = context.repo; - await github.issues.create({ - owner: owner, - repo: repo, - title: 'Release Build Failed', - body: `The release for version ${{ github.event.release.tag_name }} failed to run. Please investigate the issue.` - }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..d8ecb275 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +# This workflow is main release, needs to be manually tagged & pushed. +on: + push: + tags: + - "v1.*" + +name: Release + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ------------------------------------ + # 1) Build CLI for multiple OS/Arch + # ------------------------------------ + build-cli: + uses: ./.github/workflows/build-cli.yml + + # ------------------------------------ + # 2) Upload Install CLI Script (we only need to do this once) + # ------------------------------------ + install-script: + name: Upload Install Script + runs-on: ubuntu-latest + needs: [ build-cli ] + steps: + - uses: actions/checkout@v4 + - uses: actions/upload-artifact@v4 + with: + name: download_cli.sh + path: download_cli.sh + + # ------------------------------------------------------------ + # 3) Bundle Desktop App (macOS only) - builds goosed and Electron app + # ------------------------------------------------------------ + bundle-desktop: + uses: ./.github/workflows/bundle-desktop.yml + with: + signing: true + secrets: + CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} + CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # ------------------------------------ + # 4) Create/Update GitHub Release + # ------------------------------------ + release: + name: Release + runs-on: ubuntu-latest + needs: [ build-cli, install-script, bundle-desktop ] + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + merge-multiple: true + + # Create/update the versioned release + - name: Release versioned + uses: ncipollo/release-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + # This pattern will match both goose tar.bz2 artifacts and the Goose.zip + artifacts: | + goose-*.tar.bz2 + Goose*.zip + download_cli.sh + allowUpdates: true + omitBody: true + omitPrereleaseDuringUpdate: true + + # Create/update the stable release + - name: Release stable + uses: ncipollo/release-action@v1 + with: + tag: stable + name: Stable + token: ${{ secrets.GITHUB_TOKEN }} + artifacts: | + goose-*.tar.bz2 + Goose*.zip + download_cli.sh + allowUpdates: true + omitBody: true + omitPrereleaseDuringUpdate: true diff --git a/.github/workflows/scripts/check_licenses.py b/.github/workflows/scripts/check_licenses.py deleted file mode 100755 index 395121d6..00000000 --- a/.github/workflows/scripts/check_licenses.py +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import os -import sys -from dataclasses import dataclass -from enum import Enum -from pathlib import Path - -import requests -import tomli -import urllib3 - - -class Color(str, Enum): - """ANSI color codes with fallback for non-color terminals""" - - @staticmethod - def supports_color() -> bool: - """Check if the terminal supports color output.""" - if not hasattr(sys.stdout, "isatty"): - return False - if not sys.stdout.isatty(): - return False - - if "NO_COLOR" in os.environ: - return False - - term = os.environ.get("TERM", "") - if term == "dumb": - return False - - return True - - has_color = supports_color() - - RED = "\033[91m" if has_color else "" - GREEN = "\033[92m" if has_color else "" - RESET = "\033[0m" if has_color else "" - BOLD = "\033[1m" if has_color else "" - - -@dataclass(frozen=True) -class LicenseConfig: - allowed_licenses: frozenset[str] = frozenset( - { - "MIT", - "BSD-3-Clause", - "Apache-2.0", - "Apache License 2", - "Apache Software License", - "Python Software Foundation License", - "BSD License", - "ISC", - } - ) - exceptions: frozenset[str] = frozenset( - { - "ai-exchange", - "tiktoken", - } - ) - - -@dataclass(frozen=True) -class LicenseInfo: - license: str | None - allowed: bool = False - - def __str__(self) -> str: - status = "βœ“" if self.allowed else "βœ—" - color = Color.GREEN if self.allowed else Color.RED - return f"{color}{status}{Color.RESET} {self.license}" - - -class LicenseChecker: - def __init__(self, config: LicenseConfig = LicenseConfig()) -> None: - self.config = config - self.session = self._setup_session() - - def _setup_session(self) -> requests.Session: - session = requests.Session() - session.verify = True - max_retries = urllib3.util.Retry( - total=3, - backoff_factor=0.5, - status_forcelist=[ - 500, - 502, - 503, - 504, - ], - ) - adapter = requests.adapters.HTTPAdapter(max_retries=max_retries) - session.mount("https://", adapter) - return session - - def normalize_license(self, license_str: str | None) -> str | None: - """ - Normalize license string for comparison. - - This method takes a license string and normalizes it by: - 1. Converting to uppercase - 2. Removing 'LICENSE' or 'LICENCE' suffixes - 3. Stripping whitespace - 4. Replacing common variations with standardized forms - - Args: - license_str (str | None): The original license string to normalize. - - Returns: - str | None: The normalized license string, or None if the input was None. - """ - if not license_str: - return None - - # fmt: off - normalized = ( - license_str.upper() - .replace(" LICENSE", "") - .replace(" LICENCE", "") - .strip() - ) - # fmt: on - - replacements = { - "APACHE 2.0": "APACHE-2.0", - "APACHE SOFTWARE LICENSE": "APACHE-2.0", - "BSD": "BSD-3-CLAUSE", - "MIT LICENSE": "MIT", - "PYTHON SOFTWARE FOUNDATION": "PSF", - } - - return replacements.get(normalized, normalized) - - def get_package_license(self, package_name: str) -> str | None: - """Fetch license information from PyPI. - - Args: - package_name (str): The name of the package to fetch the license for. - - Returns: - str | None: The license of the package, or None if not found. - """ - try: - response = self.session.get( - f"https://pypi.org/pypi/{package_name}/json", - timeout=10, - ) - response.raise_for_status() - data = response.json() - - # fmt: off - license_info = ( - data["info"].get("license") or - data["info"].get("classifiers", []) - ) - # fmt: on - - if isinstance(license_info, list): - for classifier in license_info: - if classifier.startswith("License :: "): - parts = classifier.split(" :: ") - return parts[-1] - - return license_info if isinstance(license_info, str) else None - - except requests.exceptions.SSLError as e: - print(f"SSL Error fetching license for {package_name}: {e}", file=sys.stderr) - except Exception as e: - print(f"Warning: Could not fetch license for {package_name}: {e}", file=sys.stderr) - return None - - def extract_dependencies(self, toml_file: Path) -> list[str]: - """Extract all dependencies from a TOML file.""" - with open(toml_file, "rb") as f: - data = tomli.load(f) - - dependencies = [] - - # Get direct dependencies - project_deps = data.get("project", {}).get("dependencies", []) - dependencies.extend(self._parse_dependency_strings(project_deps)) - - # Get dev dependencies - tool_deps = data.get("tool", {}).get("uv", {}).get("dev-dependencies", []) - dependencies.extend(self._parse_dependency_strings(tool_deps)) - - return list(set(dependencies)) - - def _parse_dependency_strings(self, deps: list[str]) -> list[str]: - """ - Parse dependency strings to extract package names. - - Args: - deps (list[str]): A list of dependency strings to parse. - - Returns: - list[str]: A list of extracted package names. - """ - packages = [] - for dep in deps: - if "workspace = true" in dep: - continue - - # fmt: off - # Handle basic package specifiers - package = ( - dep.split(">=")[0] - .split("==")[0] - .split("<")[0] - .split(">")[0] - .strip() - ) - package = package.split("{")[0].strip() - # fmt: on - if package: - packages.append(package) - return packages - - def check_licenses(self, toml_file: Path) -> dict[str, LicenseInfo]: - """ - Check licenses for all dependencies in the TOML file. - - Args: - toml_file (Path): The path to the TOML file containing the dependencies. - - Returns: - dict[str, LicenseInfo]: A dictionary where the keys are package names and the values are LicenseInfo objects - containing the license information and whether it's allowed.""" - dependencies = self.extract_dependencies(toml_file) - results: dict[str, LicenseInfo] = {} - checked: set[str] = set() - - for package in dependencies: - if package in checked: - continue - - checked.add(package) - results[package] = self._check_package(package) - - return results - - def _check_package(self, package: str) -> LicenseInfo: - """ - Check license for a single package. - - Args: - package (str): The name of the package to check. - - Returns: - LicenseInfo: A LicenseInfo object containing the license - information and whether it's allowed. - """ - - if package in self.config.exceptions: - return LicenseInfo("Approved Exception", True) - - license_info = self.get_package_license(package) - normalized_license = self.normalize_license(license_info) - allowed = False - - # fmt: off - if normalized_license: - allowed = normalized_license in { - self.normalize_license(x) - for x in self.config.allowed_licenses - } - # fmt: on - return LicenseInfo(license_info, allowed) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Check package licenses in TOML files") - parser.add_argument("toml_files", type=Path, nargs="*", help="Paths to TOML files") - parser.add_argument("--supported-licenses", action="store_true", help="Print supported licenses") - - checker = LicenseChecker() - all_results: dict[str, LicenseInfo] = {} - - args = parser.parse_args() - if args.supported_licenses: - for license in sorted(checker.config.allowed_licenses, key=str.casefold): - print(f" - {license}") - sys.exit(0) - - if not args.toml_files: - print("Error: No TOML files specified", file=sys.stderr) - parser.print_help() - sys.exit(1) - - for toml_file in args.toml_files: - results = checker.check_licenses(toml_file) - for package, info in results.items(): - if package in all_results and all_results[package] != info: - print(f"Warning: Package {package} has conflicting license info:", file=sys.stderr) - print(f" {toml_file}: {info}", file=sys.stderr) - print(f" Previous: {all_results[package]}", file=sys.stderr) - all_results[package] = info - - max_package_length = max(len(package) for package in all_results.keys()) - any_disallowed = False - - for package, info in sorted(all_results.items()): - if Color.has_color: - package_name = f"{Color.BOLD}{package}{Color.RESET}" - padding = len(Color.BOLD) + len(Color.RESET) - else: - package_name = package - padding = 0 - - print(f"{package_name:<{max_package_length + padding}} {info}") - if not info.allowed: - any_disallowed = True - - sys.exit(1 if any_disallowed else 0) - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/scripts/test_check_licenses.py b/.github/workflows/scripts/test_check_licenses.py deleted file mode 100644 index 3ec951de..00000000 --- a/.github/workflows/scripts/test_check_licenses.py +++ /dev/null @@ -1,248 +0,0 @@ -from pathlib import Path -from typing import Optional -from unittest.mock import Mock, patch - -import pytest -import tomli -from check_licenses import Color, LicenseChecker, LicenseConfig, LicenseInfo, main - - -@pytest.fixture -def checker() -> LicenseChecker: - return LicenseChecker() - - -@pytest.fixture -def mock_pypi_response() -> Mock: - response = Mock() - response.status_code = 200 - response.raise_for_status = Mock() - response.ok = True - response.json.return_value = { - "info": { - "license": "Apache-2.0", - } - } - return response - - -@pytest.fixture -def mock_toml_content() -> str: - return """ -[project] -dependencies = [ - "requests>=2.28.0", - "tomli==2.0.1", - "urllib3<2.0.0", - "package-with-workspace{workspace = true}", -] - -[tool.uv] -dev-dependencies = [ - "pytest>=7.0.0", - "black==23.3.0", -] -""" - - -@pytest.fixture -def mock_toml_files(tmp_path: Path) -> list[Path]: - """Create mock TOML files with different dependencies.""" - file1 = tmp_path / "pyproject1.toml" - - file1.write_text(""" -[project] -dependencies = [ - "requests>=2.28.0", - "tomli==2.0.1", -] -""") - - file2 = tmp_path / "pyproject2.toml" - file2.write_text(""" -[project] -dependencies = [ - "urllib3<2.0.0", - "requests>=2.27.0", # Different version but same package -] -""") - - return [file1, file2] - - -def test_normalize_license_variations(checker: LicenseChecker) -> None: - assert checker.normalize_license("MIT License") == "MIT" - assert checker.normalize_license("Apache 2.0") == "APACHE-2.0" - assert checker.normalize_license("BSD") == "BSD-3-CLAUSE" - assert checker.normalize_license(None) is None - assert checker.normalize_license("") is None - assert checker.normalize_license("MIT License") == "MIT" - assert checker.normalize_license("Apache 2.0") == "APACHE-2.0" - assert checker.normalize_license("BSD") == "BSD-3-CLAUSE" - assert checker.normalize_license(None) is None - assert checker.normalize_license("") is None - - -@patch.object(LicenseChecker, "get_package_license") -def test_package_license_verification(mock_get_license: Mock, checker: LicenseChecker) -> None: - def check_package_license( - package: str, license: Optional[str], expected_allowed: bool, expected_license: str - ) -> None: - if license: - mock_get_license.return_value = license - result = checker._check_package(package=package) - assert result.allowed is expected_allowed - assert result.license == expected_license - - check_package_license( - package="tiktoken", - license=None, - expected_allowed=True, - expected_license="Approved Exception", - ) - check_package_license( - package="requests", - license="Apache-2.0", - expected_allowed=True, - expected_license="Apache-2.0", - ) - check_package_license( - package="gpl-package", - license="GPL", - expected_allowed=False, - expected_license="GPL", - ) - - -def test_color_support_with_environment_variables(monkeypatch: pytest.MonkeyPatch) -> None: - def verify_color_support_disabled(*, env_var: str, value: str) -> None: - monkeypatch.setenv(env_var, value) - assert not Color.supports_color() - monkeypatch.undo() - - verify_color_support_disabled(env_var="NO_COLOR", value="1") - verify_color_support_disabled(env_var="TERM", value="dumb") - - -@patch("tomli.load") -@patch("builtins.open") -def test_extract_dependencies( - mock_open: Mock, mock_tomli_load: Mock, checker: LicenseChecker, mock_toml_content: str -) -> None: - mock_tomli_load.return_value = tomli.loads(mock_toml_content) - mock_file = Mock() - mock_open.return_value.__enter__.return_value = mock_file - - dependencies = checker.extract_dependencies(Path("mock_pyproject.toml")) - expected = ["requests", "tomli", "urllib3", "pytest", "black"] - assert sorted(dependencies) == sorted(expected) - - -@patch("requests.Session") -def test_get_package_license(mock_session: Mock, checker: LicenseChecker, mock_pypi_response: Mock) -> None: - mock_session.return_value.get.return_value = mock_pypi_response - assert checker.get_package_license("requests") == "Apache-2.0" - - # test exception handling - mock_session.return_value.get.side_effect = Exception("error") - assert checker.get_package_license("nonexistent-package") is None - - -def test_license_info_string_representation() -> None: - def assert_license_info_str(info: LicenseInfo, no_color_expected: str, color_expected: str) -> None: - expected = color_expected if Color.has_color else no_color_expected - assert str(info) == expected - - assert_license_info_str(LicenseInfo("MIT", True), "βœ“ MIT", f"{Color.GREEN}βœ“{Color.RESET} MIT") - assert_license_info_str(LicenseInfo("GPL", False), "βœ— GPL", f"{Color.RED}βœ—{Color.RESET} GPL") - - -def test_custom_license_config() -> None: - custom_config = LicenseConfig( - allowed_licenses=frozenset({"MIT", "Apache-2.0"}), exceptions=frozenset({"special-package"}) - ) - checker = LicenseChecker(config=custom_config) - - assert "special-package" in checker.config.exceptions - assert len(checker.config.allowed_licenses) == 2 - - -def test_dependency_parsing_scenarios() -> None: - """Test various dependency parsing scenarios.""" - - def parse_dependencies(toml_string: str) -> list[str]: - checker = LicenseChecker() - return checker._parse_dependency_strings(tomli.loads(toml_string)["project"]["dependencies"]) - - # basic version specifiers - parsed = parse_dependencies(""" - [project] - dependencies = [ - "requests>=2.28.0", - "tomli==2.0.1", - "urllib3<2.0.0", - ] - """) - assert sorted(parsed) == sorted(["requests", "tomli", "urllib3"]) - - # workspace dependencies - parsed = parse_dependencies(""" - [project] - dependencies = [ - "package-with-workspace{workspace = true}", - ] - """) - assert parsed == [] - - # mixed dependencies - parsed = parse_dependencies(""" - [project] - dependencies = [ - "requests>=2.28.0", - "package-with-workspace{workspace = true}", - "urllib3<2.0.0", - ] - """) - assert sorted(parsed) == sorted(["requests", "urllib3"]) - - # multiple version constraints - parsed = parse_dependencies(""" - [project] - dependencies = [ - "urllib3>=1.25.4,<2.0.0", - "requests>=2.28.0,<3.0.0", - ] - """) - assert sorted(parsed) == sorted(["urllib3", "requests"]) - - # empty dependencies - parsed = parse_dependencies(""" - [project] - dependencies = [] - """) - assert parsed == [] - - -@patch.object(LicenseChecker, "get_package_license") -def test_multiple_toml_files( - mock_get_license: Mock, - mock_toml_files: list[Path], - capsys: pytest.CaptureFixture, -) -> None: - def get_license(package: str) -> Optional[str]: - licenses = {"requests": "Apache-2.0", "tomli": "MIT", "urllib3": "MIT"} - return licenses.get(package) - - mock_get_license.side_effect = get_license - - with patch("sys.argv", ["check_licenses.py"] + [str(f) for f in mock_toml_files]): - try: - main() - except SystemExit as e: - assert e.code == 0 - - captured = capsys.readouterr() - assert "requests" in captured.out - assert "tomli" in captured.out - assert "urllib3" in captured.out - assert "βœ—" not in captured.out diff --git a/.github/workflows/test-events/pull_request.json b/.github/workflows/test-events/pull_request.json deleted file mode 100644 index b9984687..00000000 --- a/.github/workflows/test-events/pull_request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "pull_request": { - "head": { - "ref": "test-branch" - }, - "base": { - "ref": "main" - }, - "number": 123, - "title": "test: Update dependency licenses" - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index c78031b7..4f3d40f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,135 +1,42 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: +run_cli.sh +tokenizer_files/ +.DS_Store +.idea *.log -local_settings.py +tmp/ -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ +# Generated by Cargo +# will have compiled files and executables +debug/ target/ -# Jupyter Notebook -.ipynb_checkpoints +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock -# IPython -profile_default/ -ipython_config.py +# These are backup files generated by rustfmt +**/*.rs.bk -# pyenv -.python-version +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock +# UI +./ui/desktop/node_modules +./ui/desktop/out +# Hermit +/.hermit/ +/bin/ -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ +debug_*.txt -# Celery stuff -celerybeat-schedule -celerybeat.pid +# Docs +# Dependencies +/node_modules -# SageMath parsed files -*.sage.py +# Production +/build -# Environments -.env -.env.* -.venv - -# exception for local langfuse init vars -!**/packages/exchange/.env.langfuse.local - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json - -# VSCode -.vscode/* -!.vscode/settings.json -!.vscode/extensions.json - -# Autogenerated docs files -docs/docs/reference - -# uv lock file -uv.lock - -# local files -.DS_Store +# Generated files +.docusaurus +.cache-loader \ No newline at end of file diff --git a/.goosehints b/.goosehints index bb6acad1..4eaa6999 100644 --- a/.goosehints +++ b/.goosehints @@ -1,13 +1,27 @@ -This is a python CLI app that uses UV. Read CONTRIBUTING.md for information on how to build and test it as needed. +You are an expert programmer in Rust teaching who is teaching another developer who is learning Rust. +The students are familiar with programming in languages such as Python (advanced), Java (novice) and C (novice) so +when possible use analogies from those languages. -Some key concepts are that it is run as a command line interface, dependes on the "ai-exchange" package (which is in packages/exchange in this repo), and has the concept of toolkits which are ways that its behavior can be extended. Look in src/goose and tests. +Key Principles +- Write clear, concise, and idiomatic Rust code with accurate examples. +- Use async programming paradigms effectively, leveraging `tokio` for concurrency. +- Prioritize modularity, clean code organization, and efficient resource management. +- Use expressive variable names that convey intent (e.g., `is_ready`, `has_data`). +- Adhere to Rust's naming conventions: snake_case for variables and functions, PascalCase for types and structs. +- Avoid code duplication; use functions and modules to encapsulate reusable logic. +- Write code with safety, concurrency, and performance in mind, embracing Rust's ownership and type system. -Assume the user has UV installed and ensure UV is used to run any python related commands. +Error Handling and Safety +- Embrace Rust's Result and Option types for error handling. +- Use `?` operator to propagate errors in async functions. +- Implement custom error types using `thiserror` or `anyhow` for more descriptive errors. +- Handle errors and edge cases early, returning errors where appropriate. +- Use `.await` responsibly, ensuring safe points for context switching. -To run tests: +Key Conventions +1. Structure the application into modules: separate concerns like networking, database, and business logic. +2. Use environment variables for configuration management (e.g., `dotenv` crate). +3. Ensure code is well-documented with inline comments and Rustdoc. +4. Do not use the older style of "MOD/mod.rs" for separing modules and instead use the "MOD.rs" filename convention. -```sh -uv sync && uv run pytest tests -m 'not integration' -``` - -ideally after each change \ No newline at end of file +Refer to "The Rust Programming Language" book (2024 version) and "Command line apps in Rust" documentation for in-depth information on best practices, and advanced features. diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..3d471b4e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +cd ui/desktop && npx lint-staged \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index 41dc24f9..00000000 --- a/.ruff.toml +++ /dev/null @@ -1,6 +0,0 @@ -lint.select = ["E", "W", "F", "N", "ANN"] -lint.ignore = ["ANN101"] -exclude = [ - "docs", -] -line-length = 120 diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index ff5306bb..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "recommendations": [ - "ms-python.debugpy", - "ms-python.python", - "charliermarsh.ruff" - ] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 53ad0ac9..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "[python]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.organizeImports": "explicit" - } - }, - "python.testing.pytestArgs": [ - "tests" - ], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true -} \ No newline at end of file diff --git a/ACCEPTABLE_USAGE.md b/ACCEPTABLE_USAGE.md deleted file mode 100644 index be7c4f5d..00000000 --- a/ACCEPTABLE_USAGE.md +++ /dev/null @@ -1,57 +0,0 @@ -# Acceptable Usage Policy (AUP) - ---- - -We want everyone to use Goose safely and responsibly. You agree you will not use, or allow others to use, Goose to: - -### 1. Violate the law or others’ rights, including use of Goose to: - -a. Engage in, promote, generate, contribute to, encourage, plan, incite, or further illegal or unlawful activity or content, such as: - - Violence or terrorism - - Exploitation or harm to children, including the solicitation, creation, acquisition, or dissemination of child exploitative content or failure to report Child Sexual Abuse Material - - Human trafficking, exploitation, and sexual violence - - The illegal distribution of information or materials to minors - - Sexual solicitation - - Any other criminal activity - -b. Engage in, promote, incite, or facilitate the harassment, abuse, threatening, or bullying of individuals or groups of individuals. - -c. Engage in, promote, incite, or facilitate discrimination or other unlawful or harmful conduct in the provision of employment, employment benefits, credit, housing, other economic benefits, or other essential goods and services. - -d. Violate any person's privacy rights as defined by applicable privacy laws, such as sharing personal information without consent, accessing private data unlawfully, or violating any relevant privacy regulations. - -e. Misuse, collect, solicit, or gain access to private information without permission, such as non-public contact details, health data, biometric or neural data (including facial recognition), or confidential or proprietary data. - -f. Infringe or misappropriate any third-party rights, including intellectual property rights. - -g. Create, generate, or facilitate the creation of malicious code, malware, computer viruses, or do anything else in an intentional or malicious way without third-party consent that could disable, overburden, interfere with, or impair the proper working, integrity, operation, or appearance of a website or computer system. - -### 2. Engage in, promote, incite, facilitate, or assist in the planning or development of activities that present a risk of death or bodily harm to individuals, including use of Goose related to the following: - -a. Guns and illegal weapons (including weapon development). - -b. Illegal drugs and regulated/controlled substances. - -c. Any content intended to incite or promote violence, abuse, or any infliction of bodily harm to an individual. - -### 3. Intentionally deceive or mislead others or engage in other abusive or fraudulent activities, including use of Goose related to the following: - -a. Generating, promoting, or furthering fraud or fraudulent activities, scams, phishing, or malware. - -b. Generating, promoting, or furthering defamatory content, including the creation of defamatory statements, images, or other content. - -c. Impersonating another individual without consent, authorization, or legal right. - -d. Generating or facilitating false online engagement, including fake reviews and other means of fake online engagement. - -e. Plagiarism or other forms of academic dishonesty. - -f. Compromising security systems or gaining unauthorized access to computer systems or networks without authorization or permission from the affected party, such as spoofing. - ---- - -You also agree that you will not use, or allow others to use, Goose in violation of any agreements that you have with, or commitments you have made to, third parties. This may include any licenses, agreements, and other terms that apply to the models you use with Goose. - ---- - -**Please report any violation of this Policy through the following means:** [open-source-governance@block.xyz](mailto:open-source-governance@block.xyz) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 578467b0..1a9292f5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,12 +1,12 @@ # Architecture -## The System +## The Extension System Goose extends the capabilities of high-performing LLMs through a small collection of tools. This lets you instruct goose, currently via a CLI interface, to automatically solve problems on your behalf. It attempts to not just tell you how you can do something, but to actually do it for you. -The primary mode of goose (the "developer" toolkit) has access to tools to +The primary mode of goose (the "developer" extension) has access to tools to - maintain a plan - run shell commands @@ -40,7 +40,7 @@ that you should be able to observe by using it. ## Implementation The core execution logic for generation and tool calling is handled by [exchange][exchange]. -It hooks python functions into the model tool use loop, while defining very careful error handling +It hooks rust functions into the model tool use loop, while defining very careful error handling so any failures in tools are surfaced to the model. Once we've created an *exchange* object, running the process is effectively just calling @@ -50,7 +50,7 @@ Once we've created an *exchange* object, running the process is effectively just Goose builds that exchange: - allows users to configure a profile to customize capabilities -- provides a pluggable system for adding tools and prompts +- provides a pluggable extension system for adding tools and prompts - sets up the tools to interact with state We expect that goose will have multiple UXs over time, and be run in different @@ -60,28 +60,29 @@ notifications on stdout). Goose then constructs the exchange for the UX, the UX only interacts with that exchange. -``` -def build_exchange(profile: Profile, notifier: Notifier) -> Exchange: +```rust +fn build_exchange(profile: Profile, notifier: Notifier) -> Exchange { ... +} ``` -But to setup a configurable system, Goose uses `Toolkit`s: +But to setup a configurable system, Goose uses `Extensions`: ``` -(Profile, Notifier) -> [Toolkits] -> Exchange +(Profile, Notifier) -> [Extensions] -> Exchange ``` ## Profile A profile specifies some basic configuration in Goose, such as which models it should use, as well -as which toolkits it should include. +as which extensions it should include. ```yaml -processor: openai:gpt-4o -accelerator: openai:gpt-4o-mini +processor: openai:gpt-4 +accelerator: openai:gpt-4-turbo moderator: passive -toolkits: - - assistant +extensions: + - developer - calendar - contacts - name: scheduling @@ -93,16 +94,14 @@ toolkits: ## Notifier -The notifier is a concrete implementation of the Notifier base class provided by each UX. It -needs to support two methods +The notifier is a concrete implementation of the Notifier trait provided by each UX. It +needs to support two methods: -```python -class Notifier: - def log(self, RichRenderable): - ... - - def status(self, str): - ... +```rust +trait Notifier { + fn log(&self, content: RichRenderable); + fn status(&self, message: String); +} ``` Log is meant to record something concrete that happened, such as a tool being called, and status is intended @@ -110,57 +109,69 @@ for transient displays of the current status. For example, while a shell command `.log` to record the command that started, and then update the status to `"shell command running"`. Log is durable while Status is ephemeral. -## Toolkits +## Extensions -Toolkits are a collection of tools, along with the state and prompting they require. -Toolkits are what gives Goose its capabilities. +Extensions are a collection of tools, along with the state and prompting they require. +Extensions are what gives Goose its capabilities. Tools need a way to report what's happening back to the user, which we treat similarly -to logging. To make that possible, toolkits get a reference to the interface described above. +to logging. To make that possible, extensions get a reference to the interface described above. -```python -class ScheduleToolkit(Toolkit): - def __init__(self, notifier: Notifier, requires: Requirements, **kwargs): - super().__init__(notifier, requires, **kwargs) # handles the interface, exchangeview - - # for a class that has requirements, you can get them like this - self.calendar = requires.get("calendar") - self.assistant = requires.get("assistant") - self.contacts = requires.get("contacts") - - self.appointments_state = [] +```rust +struct ScheduleExtension { + notifier: Box, + calendar: Box, + assistant: Box, + contacts: Box, + appointments_state: Vec, +} - def prompt(self) -> str: - return "Try out the example tool." +impl Extension for ScheduleExtension { + fn new(notifier: Box, requires: Requirements) -> Self { + Self { + notifier, + calendar: requires.get("calendar"), + assistant: requires.get("assistant"), + contacts: requires.get("contacts"), + appointments_state: vec![], + } + } - @tool - def example(self): - self.interface.log(f"An example tool was called, current state is {self.state}") + fn prompt(&self) -> String { + "Try out the example tool.".to_string() + } + + #[tool] + fn example(&self) { + self.notifier.log(format!("An example tool was called, current state is {:?}", self.appointments_state)); + } +} ``` ### Advanced -**Dependencies**: Toolkits can depend on each other, to make it easier to get plugins to extend -or modify existing capabilities. In the config above, you can see this used for the scheduling toolkit. +**Dependencies**: Extensions can depend on each other, to make it easier to get plugins to extend +or modify existing capabilities. In the config above, you can see this used for the scheduling extension. You can refer to those requirements in code through: -```python -@tool -def example_dependency(self): - appointments = self.dependencies["calendar"].appointments - ... +```rust +#[tool] +fn example_dependency(&self) { + let appointments = self.calendar.appointments(); + // ... +} ``` - **ExchangeView**: It can also be useful for tools to have a read-only copy of the history -of the loop so far. So for advanced use cases, toolkits also have access to an +of the loop so far. So for advanced use cases, extensions also have access to an `ExchangeView` object. -```python -@tool -def example_history(self): - last_message = self.exchange_view.processor.messages[-1] - ... +```rust +#[tool] +fn example_history(&self) { + let last_message = self.exchange_view.processor.messages.last(); + // ... +} ``` -[exchange]: https://github.com/block/goose/tree/main/packages/exchange +[exchange]: https://github.com/block/goose/tree/main/packages/exchange \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index d153aa32..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,144 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [0.9.11] - 2024-11-05 -- fix: removed the unexpected tracing message (#219) - -## [0.9.10] - 2024-11-05 -- fix: bump version of exchange to 0.9.9 - -## [0.9.9] - 2024-11-04 -- fix: only run summarize when there is something to summarize vs up front (#195) -- feat: add browser toolkit (#179) -- docs: reworks README to be outcome based (#151) -- chore: license-checker improvements (#212) -- fix: prompt rendering (#208) -- fix: Cost calculation enhancement (#207) -- refactor: used recommended_models function as default model (#209) -- feat: default to synopsis (#206) -- chore: housekeeping (#202) -- feat: reduce tool entrypoints in synopsis for text editor, bash, process manager (#191) -- feat: list moderators (#204) -- chore: Minor changes in providers envs logic (#161) -- chore: add `.vscode` workspace settings and suggested extensions (#200) -- feat: license checker (#201) -- feat: include new anthropic model in docs and recommended config (#198) -- feat: tiny change so you know what processing is doing (#196) -- docs: correct intellij link (#197) -- docs: remove small duplication (#194) -- chore: add tracing option to run and group traces under session name (#187) -- docs: fix mkdocs, update to new references (#193) -- feat: support optional params jsonschema conversion in exchange (#188) -- fix: correct context loading from session new/overwrite and resume (#180) -- feat: trying a license checker (#184) -- docs: getting index.md in sync with readme (#183) -- chore: Update block-open-source ref to block (#181) -- fix: just adding stuff from developer.py to synopsis developer (#182) -- fix: Simplifed langfuse auth check (#177) -- test: add vision tests for google (#160) -- fix: adding a release checker (#176) - -## [0.9.8] - 2024-10-20 -- fix: added specific ai-exchange version and check path is None to avoid error after starting goose (#174) - -## [0.9.7] - 2024-10-18 -- chore: update tiktoken to support python 3.13 - -## [0.9.6] - 2024-10-18 -- fix: update summarizer_exchange model to use default model from input exchange (#139) -- feat: Add synopisis approach for the core goose loop (#166) -- feat: web browsing (#154) -- chore: restrict python version (#162) -- feat: Run with resume session (#153) -- refactor: move langfuse wrapper to a module in exchange instead of a package (#138) -- docs: add subheaders to the 'Other ways to run Goose' section (#155) -- fix: Remove tools from exchange when summarizing files (#157) -- chore: use primitives instead of typing imports and fixes completion … (#149) -- chore: make vcr tests pretty-print JSON (#146) - -## [0.9.5] - 2024-10-15 -- chore: updates ollama default model from mistral-nemo to qwen2.5 (#150) -- feat: add vision support for Google (#141) -- fix: session resume with arg handled incorrectly (#145) -- docs: add release instructions to CONTRIBUTING.md (#143) -- docs: add link to action, IDE words (#140) -- docs: goosehints doc fix only (#142) - -## [0.9.4] - 2024-10-10 - -- revert: "feat: add local langfuse tracing option (#106)" -- feat: add local langfuse tracing option (#106) -- feat: add groq provider (#134) -- feat: add a deep thinking reasoner model (o1-preview/mini) (#68) -- fix: use concrete SessionNotifier (#135) -- feat: add guards to session management (#101) -- fix: Set default model configuration for the Google provider. (#131) -- test: convert Google Gemini tests to VCR (#118) -- chore: Add goose providers list command (#116) -- docs: working ollama for desktop (#125) -- docs: format and clean up warnings/errors (#120) -- docs: update deploy workflow (#124) -- feat: Implement a goose run command (#121) -- feat: saved api_key to keychain for user (#104) -- docs: add callout plugin (#119) -- chore: add a page to docs for Goose application examples (#117) -- fix: exit the goose and show the error message when provider environment variable is not set (#103) -- fix: Update OpenAI pricing per https://openai.com/api/pricing/ (#110) -- fix: update developer tool prompts to use plan task status to match allowable statuses update_plan tool call (#107) -- fix: removed the panel in the output so that the user won't have unnecessary pane borders in the copied content (#109) -- docs: update links to exchange to the new location (#108) -- chore: setup workspace for exchange (#105) -- fix: resolve uvx when using a git client or IDE (#98) -- ci: add include-markdown for mkdocs (#100) -- chore: fix broken badge on readme (#102) -- feat: add global optional user goosehints file (#73) -- docs: update docs (#99) - -## [0.9.3] - 2024-09-25 - -- feat: auto save sessions before next user input (#94) -- fix: removed the diff when default profile changes (#92) -- feat: add shell-completions subcommand (#76) -- chore: update readme! (#96) -- chore: update docs again (#77) -- fix: remove overly general match for long running commands (#87) -- fix: default ollama to tested model (#88) -- fix: Resize file in screen toolkit (#81) -- fix: enhance shell() to know when it is interactive (#66) -- docs: document how to run goose fully from source from any dir (#83) -- feat: track cost and token usage in log file (#80) -- chore: add link to docs in read me (#85) -- docs: add in ollama (#82) -- chore: add just command for releasing goose (#55) -- feat: support markdown plans (#79) -- feat: add version options (#74) -- docs: fixing exchange url to public version (#67) -- docs: Update CONTRIBUTING.md (#69) -- chore: create mkdocs for goose (#70) -- docs: fix broken link (#71) -- feat: give commands the ability to execute logic (#63) -- feat: jira toolkit (#59) -- feat: run goose in a docker-style sandbox (#44) - -## [0.9.0] - 2024-09-10 - -This also updates the minimum version of exchange to 0.9.0. - -- fix: goose should track files it reads and not overwrite changes (#46) -- docs: Small dev notes for using exchange from source (#50) -- fix: typo in exchange method rewind (#54) -- fix: remove unsafe pop of messages (#47) -- chore: Update LICENSE (#53) -- chore(docs): update is_dangerous_command method description (#48) -- refactor: improve safety rails speed and prompt (#45) -- feat: make goosehints jinja templated (#43) -- ci: enforce PR title follows conventional commit (#14) -- feat: show available toolkits (#37) -- feat: adding in ability to provide per repo hints (#32) -- chore: apply ruff and add to CI (#40) -- feat: added some regex based checks for dangerous commands (#38) -- chore: Update publish github workflow to check package versions before publishing (#19) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14d1b826..0f5185f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,114 +1,101 @@ # Contributing -We welcome Pull Requests for general contributions. If you have a larger new feature or any questions on how to develop a fix, we recommend you open an [issue][issues] before starting. +Goose is Open Source! + +We welcome Pull Requests for general contributions! If you have a larger new feature or any questions on how to develop a fix, we recommend you open an [issue][issues] before starting. ## Prerequisites -Goose uses [uv][uv] for dependency management, and formats with [ruff][ruff]. -Clone goose and make sure you have installed `uv` to get started. When you use -`uv` below in your local goose directly, it will automatically setup the virtualenv -and install dependencies. +Goose includes rust binaries alongside an electron app for the GUI. To work +on the rust backend, you will need to [install rust and cargo][rustup]. To work +on the App, you will also need to [install node and npm][nvm] - we recommend through nvm. We provide a shortcut to standard commands using [just][just] in our `justfile`. -## Development +## Getting Started -Now that you have a local environment, you can make edits and run our tests! +### Rust -### Run Goose - -If you've made edits and want to try them out, use +First let's compile goose and try it out ``` -uv run goose session start +cargo build ``` -or other `goose` commands. - -If you want to run your local changes but in another directory, you can use the path in -the virtualenv created by uv: +when that is done, you should now have debug builds of the binaries like the goose cli: ``` -alias goosedev=`uv run which goose` +./target/debug/goose --help ``` -You can then run `goosedev` from another dir and it will use your current changes. +If you haven't used the CLI before, you can use this compiled version to do first time configuration: -### Run Tests - -To run the test suite against your edges, use `pytest`: - -```sh -uv run pytest tests -m "not integration" +``` +./target/debug/goose configure ``` -or, as a shortcut, +And then once you have a connection to an LLM provider working, you can run a session! -```sh -just test +``` +./target/debug/goose session ``` -### Enable traces in Goose with [locally hosted Langfuse](https://langfuse.com/docs/deployment/self-host) -> [!NOTE] -> This integration is experimental and we don't currently have integration tests for it. - -Developers can use locally hosted Langfuse tracing by applying the custom `observe_wrapper` decorator defined in `packages/exchange/src/exchange/observers` to functions for automatic integration with Langfuse, and potentially other observability providers in the future. +These same commands can be recompiled and immediately run using `cargo run -p goose-cli` for iteration. +As you make changes to the rust code, you can try it out on the CLI, or also run checks and tests: + +``` +cargo check # do your changes compile +cargo test # do the tests pass with your changes. +``` + +### Node + +Now let's make sure you can run the app. + +``` +just run-ui +``` + +The start gui will both build a release build of rust (as if you had done `cargo build -r`) and start the electron process. +You should see the app open a window, and drop you into first time setup. When you've gone through the setup, +you can talk to goose! + +You can now make changes in the code in ui/desktop to iterate on the GUI half of goose. + +## Env Vars + +You may want to make more frequent changes to your provider setup or similar to test things out +as a developer. You can use environment variables to change things on the fly without redoing +your configuration. + +> [!TIP] +> At the moment, we are still updating some of the CLI configuration to make sure this is +> respected. + +You can change the provider goose points to via the `GOOSE_PROVIDER` env var. If you already +have a credential for that provider in your keychain from previously setting up, it should +reuse it. For things like automations or to test without doing official setup, you can also +set the relevant env vars for that provider. For example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, +or `DATABRICKS_HOST`. Refer to the provider details for more info on required keys. + +## Enable traces in Goose with [locally hosted Langfuse](https://langfuse.com/docs/deployment/self-host) -- Add an `observers` array to your profile containing `langfuse`. - Run `just langfuse-server` to start your local Langfuse server. It requires Docker. - Go to http://localhost:3000 and log in with the default email/password output by the shell script (values can also be found in the `.env.langfuse.local` file). -- Run Goose with the --tracing flag enabled i.e., `goose session start --tracing` -- View your traces at http://localhost:3000 +- Set the environment variables so that rust can connect to the langfuse server -`To extend tracing to additional functions, import `from exchange.observers import observe_wrapper` and use the `observe_wrapper()` decorator on functions you wish to enable tracing for. `observe_wrapper` functions the same way as Langfuse's observe decorator. - -Read more about Langfuse's decorator-based tracing [here](https://langfuse.com/docs/sdk/python/decorators). - -### Other observability plugins - -In case locally hosted Langfuse doesn't fit your needs, you can alternatively use other `observer` telemetry plugins to ingest data with the same interface as the Langfuse integration. -To do so, extend `packages/exchange/src/exchange/observers/base.py:Observer` and include the new plugin's path as an entrypoint in `exchange`'s `pyproject.toml`. - -## Exchange - -The lower level generation behind goose is powered by the [`exchange`][ai-exchange] package, also in this repo. - -Thanks to `uv` workspaces, any changes you make to `exchange` will be reflected in using your local goose. To run tests -for exchange, head to `packages/exchange` and run tests just like above - -```sh -uv run pytest tests -m "not integration" +``` +export LANGFUSE_INIT_PROJECT_PUBLIC_KEY=publickey-local +export LANGFUSE_INIT_PROJECT_SECRET_KEY=secretkey-local ``` -## Evaluations - -Given that so much of Goose involves interactions with LLMs, our unit tests only go so far to confirming things work as intended. - -We're currently developing a suite of evaluations, to make it easier to make improvements to Goose more confidently. - -In the meantime, we typically incubate any new additions that change the behavior of the Goose through **opt-in** plugins - `Toolkit`s, `Moderator`s, and `Provider`s. We welcome contributions of plugins that add new capabilities to *goose*. We recommend sending in several examples of the new capabilities in action with your pull request. - -Additions to the [developer toolkit][developer] change the core performance, and so will need to be measured carefully. +Then you can view your traces at http://localhost:3000 ## Conventional Commits This project follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification for PR titles. Conventional Commits make it easier to understand the history of a project and facilitate automation around versioning and changelog generation. -## Release - -In order to release a new version of goose, you need to do the following: -1. Update CHANGELOG.md. To get the commit messages since last release, run: `just release-notes` -2. Update version in `pyproject.toml` for `goose` and package dependencies such as `exchange` -3. Create a PR and merge it into main branch -4. Tag the HEAD commit in main branch. To do this, switch to main branch and run: `just tag-push` -5. Publish a new release from the [Github Release UI](https://github.com/block/goose/releases) - - [issues]: https://github.com/block/goose/issues -[goose-plugins]: https://github.com/block-open-source/goose-plugins -[ai-exchange]: https://github.com/block/goose/tree/main/packages/exchange -[developer]: https://github.com/block/goose/blob/dfecf829a83021b697bf2ecc1dbdd57d31727ddd/src/goose/toolkit/developer.py -[uv]: https://docs.astral.sh/uv/ -[ruff]: https://docs.astral.sh/ruff/ -[just]: https://github.com/casey/just -[adding-toolkit]: https://block.github.io/goose/configuration.html#adding-a-toolkit +[rustup]: https://doc.rust-lang.org/cargo/getting-started/installation.html +[nvm]: https://github.com/nvm-sh/nvm +[just]: https://github.com/casey/just?tab=readme-ov-file#installation diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..a2e1b5ac --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] +members = ["crates/*"] +resolver = "2" + +[workspace.package] +edition = "2021" +version = "1.0.0" +authors = ["Block "] +license = "Apache-2.0" +repository = "https://github.com/block/goose" +description = "An AI agent" diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 00000000..34698598 --- /dev/null +++ b/Cross.toml @@ -0,0 +1,29 @@ +# Configuration for cross-compiling using cross +[target.aarch64-unknown-linux-gnu] +xargo = false +pre-build = [ + # Add the ARM64 architecture and install necessary dependencies + "dpkg --add-architecture arm64", + """\ + apt-get update --fix-missing && apt-get install -y \ + pkg-config \ + libssl-dev:arm64 \ + libdbus-1-dev:arm64 \ + libxcb1-dev:arm64 + """ +] +env = { PKG_CONFIG_PATH = "/usr/lib/aarch64-linux-gnu/pkgconfig" } + +[target.x86_64-unknown-linux-gnu] +xargo = false +pre-build = [ + # Install necessary dependencies for x86_64 + # We don't need architecture-specific flags because x86_64 dependencies are installable on Ubuntu system + """\ + apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + libdbus-1-dev \ + libxcb1-dev \ + """ +] diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index d9976968..00000000 --- a/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# Use an official Python runtime as a parent image -FROM python:3.10-slim - -# Set the working directory in the container -WORKDIR /app - -# Install SSL certificates, update certifi, install pipx and move to path -RUN apt-get update && apt-get install -y ca-certificates git curl make \ - && pip install --upgrade certifi \ - && pip install pipx \ - && pipx ensurepath - -# Install goose-ai CLI using pipx -RUN pipx install goose-ai - -# Make sure the PATH is updated -ENV PATH="/root/.local/bin:${PATH}" - -# Run an infinite loop to keep the container running for testing -ENTRYPOINT ["goose", "session", "start"] - -# once built, you can run this with something like: -# docker run -it --env OPENAI_API_KEY goose-ai -# or to run against ollama running on the same host -# docker run -it --env OPENAI_HOST=http://host.docker.internal:11434/ --env OPENAI_API_KEY=unused goose-ai -# -# To use goose in a docker style sandbox for experimenting. \ No newline at end of file diff --git a/Justfile b/Justfile new file mode 100644 index 00000000..fd679205 --- /dev/null +++ b/Justfile @@ -0,0 +1,37 @@ +# Justfile + +# Default release command +release: + @echo "Building release version..." + cargo build --release + @just copy-binary + +# Copy binary command +copy-binary: + @if [ -f ./target/release/goosed ]; then \ + echo "Copying goosed binary to ui/desktop/src/bin with permissions preserved..."; \ + cp -p ./target/release/goosed ./ui/desktop/src/bin/; \ + else \ + echo "Release binary not found."; \ + exit 1; \ + fi +# Run UI with latest +run-ui: + @just release + @echo "Running UI..." + cd ui/desktop && npm install && npm run start-gui + +# Run server +run-server: + @echo "Running server..." + cargo run -p goose-server + +# make GUI with latest binary +make-ui: + @just release + cd ui/desktop && npm run bundle:default + +# Setup langfuse server +langfuse-server: + #!/usr/bin/env bash + ./scripts/setup_langfuse.sh \ No newline at end of file diff --git a/README.md b/README.md index 3a9f45fb..2003fa91 100644 --- a/README.md +++ b/README.md @@ -1,274 +1,21 @@

-Goose is your on-machine developer agent, working for you, on your terms +codename goose

- Goose Drawing -

-

- Generated by Goose from its VincentVanCode toolkit. + an open-source, extensible AI agent that goes beyond code suggestions
install, execute, edit, and test with any LLM

- - - - - - Discord + + CI +

-

-Unique features πŸ€– β€’ - Testimonials on Goose πŸ‘©β€πŸ’» β€’ -Quick start guide πŸš€ β€’ -Getting involved! πŸ‘‹ -

- -> [!TIP] -> **Quick install:** -> ``` -> pipx install goose-ai -> ``` - -Each day, we have tasks that stretch our days. Maybe it is completing that half-complete change started by someone now -on holiday, or figuring out the tl;dr; of that 50 comment pull request. Wouldn't it be grand if someone else can do -this, or at least start the work for us? - -**Goose** is your on-machine developer agent, working for you, on your terms. Guided by you, Goose intelligently -assesses what you need and generates required code or modifications. You are in charge: Do you prefer Goose to make a -draft, or complete the change entirely? Do you prefer to work in a terminal or in your IDE? - -Doing our work requires a lot of tools like Jira, GitHub, Slack, as well APIs for infrastructure and data pipelines. -Goose handles all of these, and is extensible. Goose can run anything invocable by a shell command, Python or a plugin. - -Like semi-autonomous driving, Goose handles the heavy lifting, allowing you to focus on other priorities. Simply set it -on a task and return later to find it completed, boosting your productivity with less manual effort. Read on to get -started! - -

-

- - - -## Unique features of Goose compared to other AI assistants - -- **Autonomy**: A copilot should be able to also fly the plane at times, which in the development world means running code, debugging tests, installing dependencies, not just providing text output and autocomplete or search. Goose moves beyond just generating code snippets by (1) **using the shell** and (2) by seeing what happens with the code it writes and starting a feedback loop to solve harder problems, **refining solutions iteratively like a human developer**. Your code's best wingman. - -- **Extensibility**: Open-source and fully customizable, Goose integrates with your workflow and allows you to extend it for even more control. **Toolkits let you add new capabilities to Goose.** They are anything you can implement as a Python function (e.g. API requests, deployments, search, etc). We have a growing library of toolkits to use, but more importantly you can create your own. This gives Goose the ability to run these commands and decide if and when a tool is needed to complete your request! **Creating your own toolkits give you a way to bring your own private context into Goose's capabilities.** And you can use *any* LLM you want under the hood, as long as it supports tool use. - -## What users have to say about Goose - -> With Goose, I feel like I am Maverick. -> -> Thanks a ton for creating this. πŸ™ -> I have been having way too much fun with it today. - --- P, Machine Learning Engineer - - -> I wanted to construct some fake data for an API with a large request body and business rules I haven't memorized. So I told Goose which object to update and a test to run that calls the vendor. Got it to use the errors descriptions from the vendor response to keep correcting the request until it was successful. So good! - --- J, Software Engineer - - -> I asked Goose to write up a few Google Scripts that mimic Clockwise's functionality (particularly, creating blocks on my work calendar based on events in my personal calendar, as well as color-coding calendar entries based on type and importance). Took me under an hour. If you haven't tried Goose yet, I highly encourage you to do so! - --- M, Software Engineer - - -> If anyone was looking for another reason to check it out: I just asked Goose to break a string-array into individual string resources across eleven localizations, and it performed amazingly well and saved me a bunch of time doing it manually or figuring out some way to semi-automate it. - --- A, Android Engineer - - -> Hi team, thank you for much for making Goose, it's so amazing. Our team is working on migrating Dashboard components to React components. I am working with Goose to help the migration. - --- K, Software Engineer - - -> Got Goose to update a dependency, run tests, make a branch and a commit... it was 🀌. Not that complicated but I was impressed it figured out how to run tests from the README. - --- J, Software Engineer - - -> Wanted to document what I had Goose do -- took about 30 minutes end to end! I created a custom CLI command in the `gh CLI` library to download in-line comments on PRs about code changes (currently they aren't directly viewable). I don't know Go *that well* and I definitely didn't know where to start looking in the code base or how to even test the new command was working and Goose did it all for me 😁 - --- L, Software Engineer - - -> Hi Team, just wanted to share my experience of using Goose as a non-engineer! ... I just asked Goose to ensure that my environment is up to date and copied over a guide into my prompt. Goose managed everything flawlessly, keeping me informed at every step... I was truly impressed with how well it works and how easy it was to get started! 😍 - --- M, Product Manager - -**See more of our use-cases in our [docs][use-cases]!** - -## Quick start guide - -### Installation - -To install Goose, use `pipx`. First ensure [pipx][pipx] is installed: - -``` sh -brew install pipx -pipx ensurepath -``` - -Then install Goose: - -```sh -pipx install goose-ai -``` - -### Running Goose - -#### Start a session - -From your terminal, navigate to the directory you'd like to start from and run: - -```sh -goose session start -``` - -#### Set up a provider -Goose works with your [preferred LLM][providers]. By default, it uses `openai` as the LLM provider. You'll be prompted to set an [API key][openai-key] if you haven't set one previously. - ->[!TIP] -> **Billing:** -> -> You will need to have credits in your LLM Provider account to be able to successfully make requests. -> - - -#### Make Goose do the work for you -You will see the Goose prompt `G❯`: - -``` -G❯ type your instructions here exactly as you would speak to a developer. -``` - -Now you are interacting with Goose in conversational sessions - think of it as like giving direction to a junior developer. The default toolkit allows Goose to take actions through shell commands and file edits. You can interrupt Goose with `CTRL+D` or `ESC+Enter` at any time to help redirect its efforts. - -> [!TIP] -> You can place a `.goosehints` text file in any directory you launch goose from to give it some background info for new sessions in plain language (eg how to test, what instructions to read to get started or just tell it to read the README!) You can also put a global one `~/.config/goose/.goosehints` if you like for always loaded hints personal to you. - -### Running a goose tasks (one off) - -You can run goose to do things just as a one off, such as tidying up, and then exiting: - -```sh -goose run instructions.md -``` - -You can also use process substitution to provide instructions directly from the command line: - -```sh -goose run <(echo "Create a new Python file that prints hello world") -``` - -This will run until completion as best it can. You can also pass `--resume-session` and it will re-use the first session it finds for context - - -#### Exit the session - -If you are looking to exit, use `CTRL+D`, although Goose should help you figure that out if you forget. - -#### Resume a session - -When you exit a session, it will save the history in `~/.config/goose/sessions` directory. You can then resume your last saved session later, using: - -``` sh -goose session resume -``` - -To see more documentation on the CLI commands currently available to Goose check out the documentation [here][cli]. If you’d like to develop your own CLI commands for Goose, check out the [Contributing document][contributing]. - -### Tracing with Langfuse -> [!NOTE] -> This Langfuse integration is experimental and we don't currently have integration tests for it. - -The exchange package provides a [Langfuse](https://langfuse.com/) wrapper module. The wrapper serves to initialize Langfuse appropriately if the Langfuse server is running locally and otherwise to skip applying the Langfuse observe descorators. - -#### Start your local Langfuse server - -Run `just langfuse-server` to start your local Langfuse server. It requires Docker. - -Read more about local Langfuse deployments [here](https://langfuse.com/docs/deployment/local). - -#### Exchange and Goose integration - -Import `from exchange.observers import observe_wrapper`, include `langfuse` in the `observers` list of your profile, and use the `observe_wrapper()` decorator on functions you wish to enable tracing for. `observe_wrapper` functions the same way as Langfuse's observe decorator. - -Read more about Langfuse's decorator-based tracing [here](https://langfuse.com/docs/sdk/python/decorators). - -In Goose, initialization requires certain environment variables to be present: - -- `LANGFUSE_PUBLIC_KEY`: Your Langfuse public key -- `LANGFUSE_SECRET_KEY`: Your Langfuse secret key -- `LANGFUSE_BASE_URL`: The base URL of your Langfuse instance - -By default your local deployment and Goose will use the values in `.env.langfuse.local`. - - - -### Next steps - -Learn how to modify your Goose profiles.yaml file to add and remove functionality (toolkits) and providing context to get the most out of Goose in our [Getting Started Guide][getting-started]. - -## Other ways to run goose - -**Want to move out of the terminal and into an IDE?** - -We have some experimental IDE integrations for VSCode and JetBrains IDEs: -* https://github.com/square/goose-vscode -* https://github.com/Kvadratni/goose-intellij - -**Goose as a Github Action** - -There is also an experimental Github action to run goose as part of your workflow (for example if you ask it to fix an issue): -https://github.com/marketplace/actions/goose-ai-developer-agent - -**With Docker** - -There is also a `Dockerfile` in the root of this project you can use if you want to run goose in a sandboxed fashion. - -## Getting involved! - -There is a lot to do! If you're interested in contributing, a great place to start is picking a `good-first-issue`-labelled ticket from our [issues list][gh-issues]. More details on how to develop Goose can be found in our [Contributing Guide][contributing]. We are a friendly, collaborative group and look forward to working together![^1] - - -Check out and contribute to more experimental features in [Goose Plugins][goose-plugins]! - -Let us know what you think in our [Discussions][discussions] or the [**`#goose`** channel on Discord][goose-channel]. - -[^1]: Yes, Goose is open source and always will be. Goose is released under the ASL2.0 license meaning you are free to use it however you like. See [LICENSE.md][license] for more details. - - - -[goose-plugins]: https://github.com/block-open-source/goose-plugins - -[pipx]: https://github.com/pypa/pipx?tab=readme-ov-file#install-pipx -[contributing]: https://github.com/block/goose/blob/main/CONTRIBUTING.md -[license]: https://github.com/block/goose/blob/main/LICENSE - -[goose-docs]: https://block.github.io/goose/ -[toolkits]: https://block.github.io/goose/plugins/available-toolkits.html -[configuration]: https://block.github.io/goose/configuration.html -[cli]: https://block.github.io/goose/plugins/cli.html -[providers]: https://block.github.io/goose/plugins/providers.html -[use-cases]: https://block.github.io/goose/guidance/applications.html -[getting-started]: https://block.github.io/goose/guidance/getting-started.html -[openai-key]: https://platform.openai.com/api-keys - -[discord-invite]: https://discord.gg/7GaTvbDwga -[gh-issues]: https://github.com/block/goose/issues -[van-code]: https://github.com/block-open-source/goose-plugins/blob/de98cd6c29f8e7cd3b6ace26535f24ac57c9effa/src/goose_plugins/toolkits/artify.py -[discussions]: https://github.com/block/goose/discussions -[goose-channel]: https://discord.com/channels/1287729918100246654/1287729920319033345 +Stay tuned for the upcoming 1.0 release by the end of January 2025. You can find the v0.X documentation on our [github pages](https://block.github.io/goose/). diff --git a/crates/goose-cli/.gitignore b/crates/goose-cli/.gitignore new file mode 100644 index 00000000..94a2ce17 --- /dev/null +++ b/crates/goose-cli/.gitignore @@ -0,0 +1,3 @@ + +.goosehints +.goose \ No newline at end of file diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml new file mode 100644 index 00000000..db5b34c5 --- /dev/null +++ b/crates/goose-cli/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "goose-cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description.workspace = true + +[[bin]] +name = "goose" +path = "src/main.rs" + +[dependencies] +goose = { path = "../goose" } +goose-mcp = { path = "../goose-mcp" } +mcp-client = { path = "../mcp-client" } +mcp-server = { path = "../mcp-server" } +mcp-core = { path = "../mcp-core" } +clap = { version = "4.4", features = ["derive"] } +cliclack = "0.3.5" +console = "0.15.8" +bat = "0.24.0" +anyhow = "1.0" +serde_json = "1.0" +tokio = { version = "1.0", features = ["full"] } +futures = "0.3" +serde = { version = "1.0", features = ["derive"] } # For serialization +serde_yaml = "0.9" +dirs = "4.0" +reqwest = { version = "0.12.9", features = [ + "rustls-tls", + "json", + "cookies", + "gzip", + "brotli", + "deflate", + "zstd", + "charset", + "http2", + "stream" + ], default-features = false } +rand = "0.8.5" +rustyline = "15.0.0" +tracing = "0.1" +chrono = "0.4" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] } +tracing-appender = "0.2" + +[dev-dependencies] +tempfile = "3" +temp-env = { version = "0.3.6", features = ["async_closure"] } +test-case = "3.3" +tokio = { version = "1.0", features = ["rt", "macros"] } \ No newline at end of file diff --git a/crates/goose-cli/src/commands/agent_version.rs b/crates/goose-cli/src/commands/agent_version.rs new file mode 100644 index 00000000..2f162836 --- /dev/null +++ b/crates/goose-cli/src/commands/agent_version.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use clap::Args; +use goose::agents::AgentFactory; +use std::fmt::Write; + +#[derive(Args)] +pub struct AgentCommand {} + +impl AgentCommand { + pub fn run(&self) -> Result<()> { + let mut output = String::new(); + writeln!(output, "Available agent versions:")?; + + let versions = AgentFactory::available_versions(); + let default_version = AgentFactory::default_version(); + + for version in versions { + if version == default_version { + writeln!(output, "* {} (default)", version)?; + } else { + writeln!(output, " {}", version)?; + } + } + + print!("{}", output); + Ok(()) + } +} diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs new file mode 100644 index 00000000..5a0ce341 --- /dev/null +++ b/crates/goose-cli/src/commands/configure.rs @@ -0,0 +1,486 @@ +use cliclack::spinner; +use console::style; +use goose::agents::{extension::Envs, ExtensionConfig}; +use goose::config::{Config, ExtensionEntry, ExtensionManager}; +use goose::message::Message; +use goose::providers::{create, providers}; +use serde_json::Value; +use std::collections::HashMap; +use std::error::Error; + +pub async fn handle_configure() -> Result<(), Box> { + let config = Config::global(); + + if !config.exists() { + // First time setup flow + println!(); + println!( + "{}", + style("Welcome to goose! Let's get you set up with a provider.").dim() + ); + println!( + "{}", + style(" you can rerun this command later to update your configuration").dim() + ); + println!(); + cliclack::intro(style(" goose-configure ").on_cyan().black())?; + if configure_provider_dialog().await? { + println!( + "\n {}: Run '{}' again to adjust your config or add extensions", + style("Tip").green().italic(), + style("goose configure").cyan() + ); + // Since we are setting up for the first time, we'll also enable the developer system + ExtensionManager::set(ExtensionEntry { + enabled: true, + config: ExtensionConfig::Builtin { + name: "developer".to_string(), + }, + })?; + } else { + let _ = config.clear(); + println!( + "\n {}: We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect", + style("Warning").yellow().italic(), + style("goose configure").cyan() + ); + } + + Ok(()) + } else { + println!(); + println!( + "{}", + style("This will update your existing config file").dim() + ); + println!( + "{} {}", + style(" if you prefer, you can edit it directly at").dim(), + config.path() + ); + println!(); + + cliclack::intro(style(" goose-configure ").on_cyan().black())?; + let action = cliclack::select("What would you like to configure?") + .item( + "providers", + "Configure Providers", + "Change provider or update credentials", + ) + .item( + "toggle", + "Toggle Extensions", + "Enable or disable connected extensions", + ) + .item("add", "Add Extension", "Connect to a new extension") + .interact()?; + + match action { + "toggle" => toggle_extensions_dialog(), + "add" => configure_extensions_dialog(), + "providers" => configure_provider_dialog().await.and(Ok(())), + _ => unreachable!(), + } + } +} + +/// Dialog for configuring the AI provider and model +pub async fn configure_provider_dialog() -> Result> { + // Get global config instance + let config = Config::global(); + + // Get all available providers and their metadata + let available_providers = providers(); + + // Create selection items from provider metadata + let provider_items: Vec<(&String, &str, &str)> = available_providers + .iter() + .map(|p| (&p.name, p.display_name.as_str(), p.description.as_str())) + .collect(); + + // Get current default provider if it exists + let current_provider: Option = config.get("GOOSE_PROVIDER").ok(); + let default_provider = current_provider.unwrap_or_default(); + + // Select provider + let provider_name = cliclack::select("Which model provider should we use?") + .initial_value(&default_provider) + .items(&provider_items) + .interact()?; + + // Get the selected provider's metadata + let provider_meta = available_providers + .iter() + .find(|p| &p.name == provider_name) + .expect("Selected provider must exist in metadata"); + + // Configure required provider keys + for key in &provider_meta.config_keys { + if !key.required { + continue; + } + + // First check if the value is set via environment variable + let from_env = std::env::var(&key.name).ok(); + + match from_env { + Some(env_value) => { + let _ = + cliclack::log::info(format!("{} is set via environment variable", key.name)); + if cliclack::confirm("Would you like to save this value to your config file?") + .initial_value(true) + .interact()? + { + if key.secret { + config.set_secret(&key.name, Value::String(env_value))?; + } else { + config.set(&key.name, Value::String(env_value))?; + } + let _ = cliclack::log::info(format!("Saved {} to config file", key.name)); + } + } + None => { + // No env var, check config/secret storage + let existing: Result = if key.secret { + config.get_secret(&key.name) + } else { + config.get(&key.name) + }; + + match existing { + Ok(_) => { + let _ = cliclack::log::info(format!("{} is already configured", key.name)); + if cliclack::confirm("Would you like to update this value?").interact()? { + let new_value: String = if key.secret { + cliclack::password(format!("Enter new value for {}", key.name)) + .mask('β–ͺ') + .interact()? + } else { + cliclack::input(format!("Enter new value for {}", key.name)) + .interact()? + }; + + if key.secret { + config.set_secret(&key.name, Value::String(new_value))?; + } else { + config.set(&key.name, Value::String(new_value))?; + } + } + } + Err(_) => { + let value: String = if key.secret { + cliclack::password(format!( + "Provider {} requires {}, please enter a value", + provider_meta.display_name, key.name + )) + .mask('β–ͺ') + .interact()? + } else { + cliclack::input(format!( + "Provider {} requires {}, please enter a value", + provider_meta.display_name, key.name + )) + .interact()? + }; + + if key.secret { + config.set_secret(&key.name, Value::String(value))?; + } else { + config.set(&key.name, Value::String(value))?; + } + } + } + } + } + } + + // Select model, defaulting to the provider's recommended model + let default_model = config + .get("GOOSE_MODEL") + .unwrap_or(provider_meta.default_model.clone()); + let model: String = cliclack::input("Enter a model from that provider:") + .default_input(&default_model) + .interact()?; + + // Update config with new values + config.set("GOOSE_PROVIDER", Value::String(provider_name.to_string()))?; + config.set("GOOSE_MODEL", Value::String(model.clone()))?; + + // Test the configuration + let spin = spinner(); + spin.start("Checking your configuration..."); + + let model_config = goose::model::ModelConfig::new(model.clone()); + let provider = create(provider_name, model_config)?; + + let message = Message::user().with_text( + "Please give a nice welcome message (one sentence) and let them know they are all set to use this agent" + ); + + let result = provider + .complete( + "You are an AI agent called Goose. You use tools of connected extensions to solve problems.", + &[message], + &[] + ) + .await; + + match result { + Ok((message, _usage)) => { + if let Some(content) = message.content.first() { + if let Some(text) = content.as_text() { + spin.stop(text); + } else { + spin.stop("No response text available"); + } + } else { + spin.stop("No response content available"); + } + + cliclack::outro("Configuration saved successfully")?; + Ok(true) + } + Err(e) => { + println!("{:?}", e); + spin.stop("We could not connect!"); + let _ = cliclack::outro("The provider configuration was invalid"); + Ok(false) + } + } +} + +/// Configure extensions that can be used with goose +/// Dialog for toggling which extensions are enabled/disabled +pub fn toggle_extensions_dialog() -> Result<(), Box> { + let extensions = ExtensionManager::get_all()?; + + if extensions.is_empty() { + cliclack::outro( + "No extensions configured yet. Run configure and add some extensions first.", + )?; + return Ok(()); + } + + // Create a list of extension names and their enabled status + let extension_status: Vec<(String, bool)> = extensions + .iter() + .map(|entry| (entry.config.name().to_string(), entry.enabled)) + .collect(); + + // Get currently enabled extensions for the selection + let enabled_extensions: Vec<&String> = extension_status + .iter() + .filter(|(_, enabled)| *enabled) + .map(|(name, _)| name) + .collect(); + + // Let user toggle extensions + let selected = cliclack::multiselect( + "enable extensions: (use \"space\" to toggle and \"enter\" to submit)", + ) + .required(false) + .items( + &extension_status + .iter() + .map(|(name, _)| (name, name.as_str(), "")) + .collect::>(), + ) + .initial_values(enabled_extensions) + .interact()?; + + // Update enabled status for each extension + for name in extension_status.iter().map(|(name, _)| name) { + ExtensionManager::set_enabled(name, selected.iter().any(|s| s.as_str() == name))?; + } + + cliclack::outro("Extension settings updated successfully")?; + Ok(()) +} + +pub fn configure_extensions_dialog() -> Result<(), Box> { + let extension_type = cliclack::select("What type of extension would you like to add?") + .item( + "built-in", + "Built-in Extension", + "Use an extension that comes with Goose", + ) + .item( + "stdio", + "Command-line Extension", + "Run a local command or script", + ) + .item( + "sse", + "Remote Extension", + "Connect to a remote extension via SSE", + ) + .interact()?; + + match extension_type { + // TODO we'll want a place to collect all these options, maybe just an enum in goose-mcp + "built-in" => { + let extension = cliclack::select("Which built-in extension would you like to enable?") + .item( + "developer", + "Developer Tools", + "Code editing and shell access", + ) + .item( + "nondeveloper", + "Non Developer", + "AI driven scripting for non developers", + ) + .item( + "google_drive", + "Google Drive", + "Search and read content from google drive - additional config required", + ) + .item( + "memory", + "Memory", + "Tools to save and retrieve durable memories", + ) + .item("jetbrains", "JetBrains", "Connect to jetbrains IDEs") + .interact()? + .to_string(); + + ExtensionManager::set(ExtensionEntry { + enabled: true, + config: ExtensionConfig::Builtin { + name: extension.clone(), + }, + })?; + + cliclack::outro(format!("Enabled {} extension", style(extension).green()))?; + } + "stdio" => { + let extensions = ExtensionManager::get_all_names()?; + let name: String = cliclack::input("What would you like to call this extension?") + .placeholder("my-extension") + .validate(move |input: &String| { + if input.is_empty() { + Err("Please enter a name") + } else if extensions.contains(input) { + Err("An extension with this name already exists") + } else { + Ok(()) + } + }) + .interact()?; + + let command_str: String = cliclack::input("What command should be run?") + .placeholder("npx -y @block/gdrive") + .validate(|input: &String| { + if input.is_empty() { + Err("Please enter a command") + } else { + Ok(()) + } + }) + .interact()?; + + // Split the command string into command and args + let mut parts = command_str.split_whitespace(); + let cmd = parts.next().unwrap_or("").to_string(); + let args: Vec = parts.map(String::from).collect(); + + let add_env = + cliclack::confirm("Would you like to add environment variables?").interact()?; + + let mut envs = HashMap::new(); + if add_env { + loop { + let key: String = cliclack::input("Environment variable name:") + .placeholder("API_KEY") + .interact()?; + + let value: String = cliclack::password("Environment variable value:") + .mask('β–ͺ') + .interact()?; + + envs.insert(key, value); + + if !cliclack::confirm("Add another environment variable?").interact()? { + break; + } + } + } + + ExtensionManager::set(ExtensionEntry { + enabled: true, + config: ExtensionConfig::Stdio { + name: name.clone(), + cmd, + args, + envs: Envs::new(envs), + }, + })?; + + cliclack::outro(format!("Added {} extension", style(name).green()))?; + } + "sse" => { + let extensions = ExtensionManager::get_all_names()?; + let name: String = cliclack::input("What would you like to call this extension?") + .placeholder("my-remote-extension") + .validate(move |input: &String| { + if input.is_empty() { + Err("Please enter a name") + } else if extensions.contains(input) { + Err("An extension with this name already exists") + } else { + Ok(()) + } + }) + .interact()?; + + let uri: String = cliclack::input("What is the SSE endpoint URI?") + .placeholder("http://localhost:8000/events") + .validate(|input: &String| { + if input.is_empty() { + Err("Please enter a URI") + } else if !input.starts_with("http") { + Err("URI should start with http:// or https://") + } else { + Ok(()) + } + }) + .interact()?; + + let add_env = + cliclack::confirm("Would you like to add environment variables?").interact()?; + + let mut envs = HashMap::new(); + if add_env { + loop { + let key: String = cliclack::input("Environment variable name:") + .placeholder("API_KEY") + .interact()?; + + let value: String = cliclack::password("Environment variable value:") + .mask('β–ͺ') + .interact()?; + + envs.insert(key, value); + + if !cliclack::confirm("Add another environment variable?").interact()? { + break; + } + } + } + + ExtensionManager::set(ExtensionEntry { + enabled: true, + config: ExtensionConfig::Sse { + name: name.clone(), + uri, + envs: Envs::new(envs), + }, + })?; + + cliclack::outro(format!("Added {} extension", style(name).green()))?; + } + _ => unreachable!(), + }; + + Ok(()) +} diff --git a/crates/goose-cli/src/commands/mcp.rs b/crates/goose-cli/src/commands/mcp.rs new file mode 100644 index 00000000..78cff074 --- /dev/null +++ b/crates/goose-cli/src/commands/mcp.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use goose_mcp::{ + DeveloperRouter, GoogleDriveRouter, JetBrainsRouter, MemoryRouter, NonDeveloperRouter, +}; +use mcp_server::router::RouterService; +use mcp_server::{BoundedService, ByteTransport, Server}; +use tokio::io::{stdin, stdout}; + +pub async fn run_server(name: &str) -> Result<()> { + // Initialize logging + crate::logging::setup_logging(Some(&format!("mcp-{name}")))?; + + tracing::info!("Starting MCP server"); + + let router: Option> = match name { + "developer" => Some(Box::new(RouterService(DeveloperRouter::new()))), + "nondeveloper" => Some(Box::new(RouterService(NonDeveloperRouter::new()))), + "jetbrains" => Some(Box::new(RouterService(JetBrainsRouter::new()))), + "google_drive" => { + let router = GoogleDriveRouter::new().await; + Some(Box::new(RouterService(router))) + } + "memory" => Some(Box::new(RouterService(MemoryRouter::new()))), + _ => None, + }; + + // Create and run the server + let server = Server::new(router.unwrap_or_else(|| panic!("Unknown server requested {}", name))); + let transport = ByteTransport::new(stdin(), stdout()); + + tracing::info!("Server initialized and ready to handle requests"); + Ok(server.run(transport).await?) +} diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs new file mode 100644 index 00000000..58d9f2da --- /dev/null +++ b/crates/goose-cli/src/commands/mod.rs @@ -0,0 +1,5 @@ +pub mod agent_version; +pub mod configure; +pub mod mcp; +pub mod session; +pub mod version; diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs new file mode 100644 index 00000000..bb40be01 --- /dev/null +++ b/crates/goose-cli/src/commands/session.rs @@ -0,0 +1,178 @@ +use rand::{distributions::Alphanumeric, Rng}; +use std::process; + +use crate::prompt::rustyline::RustylinePrompt; +use crate::session::{ensure_session_dir, get_most_recent_session, Session}; +use console::style; +use goose::agents::extension::{Envs, ExtensionError}; +use goose::agents::AgentFactory; +use goose::config::{Config, ExtensionConfig, ExtensionManager}; +use goose::providers::create; +use std::path::Path; + +use mcp_client::transport::Error as McpClientError; + +pub async fn build_session( + name: Option, + resume: bool, + extension: Option, + builtin: Option, +) -> Session<'static> { + // Load config and get provider/model + let config = Config::global(); + + let provider_name: String = config + .get("GOOSE_PROVIDER") + .expect("No provider configured. Run 'goose configure' first"); + let session_dir = ensure_session_dir().expect("Failed to create session directory"); + + let model: String = config + .get("GOOSE_MODEL") + .expect("No model configured. Run 'goose configure' first"); + let model_config = goose::model::ModelConfig::new(model.clone()); + let provider = create(&provider_name, model_config).expect("Failed to create provider"); + + // Create the agent + let agent_version: Option = config.get("GOOSE_AGENT").ok(); + let mut agent = match agent_version { + Some(version) => AgentFactory::create(&version, provider), + None => AgentFactory::create(AgentFactory::default_version(), provider), + } + .expect("Failed to create agent"); + + // Setup extensions for the agent + for extension in ExtensionManager::get_all().expect("should load extensions") { + if extension.enabled { + let config = extension.config.clone(); + agent + .add_extension(config.clone()) + .await + .unwrap_or_else(|e| { + let err = match e { + ExtensionError::Transport(McpClientError::StdioProcessError(inner)) => { + inner + } + _ => e.to_string(), + }; + println!("Failed to start extension: {}, {:?}", config.name(), err); + println!( + "Please check extension configuration for {}.", + config.name() + ); + process::exit(1); + }); + } + } + + // Add extension if provided + if let Some(extension_str) = extension { + let mut parts: Vec<&str> = extension_str.split_whitespace().collect(); + let mut envs = std::collections::HashMap::new(); + + // Parse environment variables (format: KEY=value) + while let Some(part) = parts.first() { + if !part.contains('=') { + break; + } + let env_part = parts.remove(0); + let (key, value) = env_part.split_once('=').unwrap(); + envs.insert(key.to_string(), value.to_string()); + } + + if parts.is_empty() { + eprintln!("No command provided in extension string"); + process::exit(1); + } + + let cmd = parts.remove(0).to_string(); + //this is an ephemeral extension so name does not matter + let name = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(8) + .map(char::from) + .collect(); + let config = ExtensionConfig::Stdio { + name, + cmd, + args: parts.iter().map(|s| s.to_string()).collect(), + envs: Envs::new(envs), + }; + + agent.add_extension(config).await.unwrap_or_else(|e| { + eprintln!("Failed to start extension: {}", e); + process::exit(1); + }); + } + + // Add builtin extension if provided + if let Some(name) = builtin { + let config = ExtensionConfig::Builtin { name }; + agent.add_extension(config).await.unwrap_or_else(|e| { + eprintln!("Failed to start builtin extension: {}", e); + process::exit(1); + }); + } + + // If resuming, try to find the session + if resume { + if let Some(ref session_name) = name { + // Try to resume specific session + let session_file = session_dir.join(format!("{}.jsonl", session_name)); + if session_file.exists() { + let prompt = Box::new(RustylinePrompt::new()); + return Session::new(agent, prompt, session_file); + } else { + eprintln!("Session '{}' not found, starting new session", session_name); + } + } else { + // Try to resume most recent session + if let Ok(session_file) = get_most_recent_session() { + let prompt = Box::new(RustylinePrompt::new()); + return Session::new(agent, prompt, session_file); + } else { + eprintln!("No previous sessions found, starting new session"); + } + } + } + + // Generate session name if not provided + let name = name.unwrap_or_else(|| { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(8) + .map(char::from) + .collect() + }); + + let session_file = session_dir.join(format!("{}.jsonl", name)); + if session_file.exists() { + eprintln!("Session '{}' already exists", name); + process::exit(1); + } + + let prompt = Box::new(RustylinePrompt::new()); + + display_session_info(resume, &provider_name, &model, &session_file); + Session::new(agent, prompt, session_file) +} + +fn display_session_info(resume: bool, provider: &str, model: &str, session_file: &Path) { + let start_session_msg = if resume { + "resuming session |" + } else { + "starting session |" + }; + println!( + "{} {} {} {} {}", + style(start_session_msg).dim(), + style("provider:").dim(), + style(provider).cyan().dim(), + style("model:").dim(), + style(model).cyan().dim(), + ); + println!( + " {} {}", + style("logging to").dim(), + style(session_file.display()).dim().cyan(), + ); +} diff --git a/crates/goose-cli/src/commands/version.rs b/crates/goose-cli/src/commands/version.rs new file mode 100644 index 00000000..632c8bac --- /dev/null +++ b/crates/goose-cli/src/commands/version.rs @@ -0,0 +1,3 @@ +pub fn print_version() { + println!(env!("CARGO_PKG_VERSION")) +} diff --git a/crates/goose-cli/src/log_usage.rs b/crates/goose-cli/src/log_usage.rs new file mode 100644 index 00000000..a7235b7f --- /dev/null +++ b/crates/goose-cli/src/log_usage.rs @@ -0,0 +1,93 @@ +use goose::providers::base::ProviderUsage; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct SessionLog { + session_file: String, + usage: Vec, +} + +pub fn log_usage(session_file: String, usage: Vec) { + let log = SessionLog { + session_file, + usage, + }; + + // Ensure log directory exists + if let Some(home_dir) = dirs::home_dir() { + let log_dir = home_dir.join(".config").join("goose").join("logs"); + if let Err(e) = std::fs::create_dir_all(&log_dir) { + eprintln!("Failed to create log directory: {}", e); + return; + } + + let log_file = log_dir.join("goose.log"); + let serialized = match serde_json::to_string(&log) { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to serialize usage log: {}", e); + return; + } + }; + + // Append to log file + if let Err(e) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_file) + .and_then(|mut file| { + std::io::Write::write_all(&mut file, serialized.as_bytes())?; + std::io::Write::write_all(&mut file, b"\n")?; + Ok(()) + }) + { + eprintln!("Failed to write to usage log file: {}", e); + } + } else { + eprintln!("Failed to write to usage log file: Failed to determine home directory"); + } +} + +#[cfg(test)] +mod tests { + use goose::providers::base::{ProviderUsage, Usage}; + + use crate::{ + log_usage::{log_usage, SessionLog}, + test_helpers::run_with_tmp_dir, + }; + + #[test] + fn test_session_logging() { + run_with_tmp_dir(|| { + let home_dir = dirs::home_dir().unwrap(); + let log_file = home_dir + .join(".config") + .join("goose") + .join("logs") + .join("goose.log"); + + log_usage( + "path.txt".to_string(), + vec![ProviderUsage::new( + "model".to_string(), + Usage::new(Some(10), Some(20), Some(30)), + )], + ); + + // Check if log file exists and contains the expected content + assert!(log_file.exists(), "Log file should exist"); + + let log_content = std::fs::read_to_string(&log_file).unwrap(); + let log: SessionLog = serde_json::from_str(&log_content).unwrap(); + + assert!(log.session_file.contains("path.txt")); + assert_eq!(log.usage[0].usage.input_tokens, Some(10)); + assert_eq!(log.usage[0].usage.output_tokens, Some(20)); + assert_eq!(log.usage[0].usage.total_tokens, Some(30)); + assert_eq!(log.usage[0].model, "model"); + + // Remove the log file after test + std::fs::remove_file(&log_file).ok(); + }) + } +} diff --git a/crates/goose-cli/src/logging.rs b/crates/goose-cli/src/logging.rs new file mode 100644 index 00000000..f7ebc534 --- /dev/null +++ b/crates/goose-cli/src/logging.rs @@ -0,0 +1,237 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; +use tracing_appender::rolling::Rotation; +use tracing_subscriber::{ + filter::LevelFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, + Registry, +}; + +use goose::tracing::langfuse_layer; + +/// Returns the directory where log files should be stored. +/// Creates the directory structure if it doesn't exist. +fn get_log_directory() -> Result { + let home = std::env::var("HOME").context("HOME environment variable not set")?; + let base_log_dir = PathBuf::from(home) + .join(".config") + .join("goose") + .join("logs") + .join("cli"); // Add cli-specific subdirectory + + // Create date-based subdirectory + let now = chrono::Local::now(); + let date_dir = base_log_dir.join(now.format("%Y-%m-%d").to_string()); + + // Ensure log directory exists + fs::create_dir_all(&date_dir).context("Failed to create log directory")?; + + Ok(date_dir) +} + +/// Sets up the logging infrastructure for the application. +/// This includes: +/// - File-based logging with JSON formatting (DEBUG level) +/// - Console output for development (INFO level) +/// - Optional Langfuse integration (DEBUG level) +pub fn setup_logging(name: Option<&str>) -> Result<()> { + // Set up file appender for goose module logs + let log_dir = get_log_directory()?; + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string(); + + // Create log file name by prefixing with timestamp + let log_filename = if name.is_some() { + format!("{}-{}.log", timestamp, name.unwrap()) + } else { + format!("{}.log", timestamp) + }; + + // Create non-rolling file appender for detailed logs + let file_appender = + tracing_appender::rolling::RollingFileAppender::new(Rotation::NEVER, log_dir, log_filename); + + // Create JSON file logging layer with all logs (DEBUG and above) + let file_layer = fmt::layer() + .with_target(true) + .with_level(true) + .with_writer(file_appender) + .with_ansi(false) + .with_file(true) + .pretty(); + + // Create console logging layer for development - INFO and above only + let console_layer = fmt::layer() + .with_target(true) + .with_level(true) + .with_ansi(true) + .with_file(true) + .with_line_number(true) + .pretty(); + + // Base filter + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + // Set default levels for different modules + EnvFilter::new("") + // Set mcp-server module to DEBUG + .add_directive("mcp_server=debug".parse().unwrap()) + // Set mcp-client to DEBUG + .add_directive("mcp_client=debug".parse().unwrap()) + // Set goose module to DEBUG + .add_directive("goose=debug".parse().unwrap()) + // Set goose-cli to INFO + .add_directive("goose_cli=info".parse().unwrap()) + // Set everything else to WARN + .add_directive(LevelFilter::WARN.into()) + }); + + // Build the subscriber with required layers + let subscriber = Registry::default() + .with(file_layer.with_filter(env_filter)) // Gets all logs + .with(console_layer.with_filter(LevelFilter::WARN)); // Controls log levels + + // Initialize with Langfuse if available + if let Some(langfuse) = langfuse_layer::create_langfuse_observer() { + subscriber + .with(langfuse.with_filter(LevelFilter::DEBUG)) + .try_init() + .context("Failed to set global subscriber")?; + } else { + subscriber + .try_init() + .context("Failed to set global subscriber")?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use tempfile::TempDir; + use test_case::test_case; + use tokio::runtime::Runtime; + + fn setup_temp_home() -> TempDir { + let temp_dir = TempDir::new().unwrap(); + env::set_var("HOME", temp_dir.path()); + temp_dir + } + + #[test] + fn test_log_directory_creation() { + let _temp_dir = setup_temp_home(); + let log_dir = get_log_directory().unwrap(); + assert!(log_dir.exists()); + assert!(log_dir.is_dir()); + + // Verify directory structure + let path_components: Vec<_> = log_dir.components().collect(); + assert!(path_components.iter().any(|c| c.as_os_str() == "goose")); + assert!(path_components.iter().any(|c| c.as_os_str() == "logs")); + assert!(path_components.iter().any(|c| c.as_os_str() == "cli")); + } + + #[test_case(Some("test_session") ; "with session name")] + #[test_case(None ; "without session name")] + fn test_log_file_name(session_name: Option<&str>) { + let _rt = Runtime::new().unwrap(); + let _temp_dir = setup_temp_home(); + + // Create a test-specific log directory and file + let log_dir = get_log_directory().unwrap(); + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string(); + let file_name = format!("{}.log", session_name.unwrap_or(×tamp)); + + // Create the log file + let file_path = log_dir.join(&file_name); + fs::write(&file_path, "test").unwrap(); + + // Verify the file exists and has the correct name + let entries = fs::read_dir(log_dir).unwrap(); + let log_files: Vec<_> = entries + .filter_map(Result::ok) + .filter(|e| e.path().extension().map_or(false, |ext| ext == "log")) + .collect(); + + assert_eq!(log_files.len(), 1, "Expected exactly one log file"); + + let log_file_name = log_files[0].file_name().to_string_lossy().into_owned(); + println!("Log file name: {}", log_file_name); + + if let Some(name) = session_name { + assert_eq!(log_file_name, format!("{}.log", name)); + } else { + // Extract just the filename without extension for comparison + let name_without_ext = log_file_name.trim_end_matches(".log"); + // Verify it's a valid timestamp format + assert_eq!( + name_without_ext.len(), + 15, + "Expected 15 characters (YYYYMMDD_HHMMSS)" + ); + assert!( + name_without_ext[8..9].contains('_'), + "Expected underscore at position 8" + ); + assert!( + name_without_ext + .chars() + .all(|c| c.is_ascii_digit() || c == '_'), + "Expected only digits and underscore" + ); + } + } + + #[tokio::test] + async fn test_langfuse_layer_creation() { + let _temp_dir = setup_temp_home(); + + // Store original environment variables (both sets) + let original_vars = [ + ("LANGFUSE_PUBLIC_KEY", env::var("LANGFUSE_PUBLIC_KEY").ok()), + ("LANGFUSE_SECRET_KEY", env::var("LANGFUSE_SECRET_KEY").ok()), + ("LANGFUSE_HOST", env::var("LANGFUSE_HOST").ok()), + ( + "LANGFUSE_INIT_PROJECT_PUBLIC_KEY", + env::var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY").ok(), + ), + ( + "LANGFUSE_INIT_PROJECT_SECRET_KEY", + env::var("LANGFUSE_INIT_PROJECT_SECRET_KEY").ok(), + ), + ]; + + // Clear all Langfuse environment variables + for (var, _) in &original_vars { + env::remove_var(var); + } + + // Test without any environment variables + assert!(langfuse_layer::create_langfuse_observer().is_none()); + + // Test with standard Langfuse variables + env::set_var("LANGFUSE_PUBLIC_KEY", "test_public_key"); + env::set_var("LANGFUSE_SECRET_KEY", "test_secret_key"); + assert!(langfuse_layer::create_langfuse_observer().is_some()); + + // Clear and test with init project variables + env::remove_var("LANGFUSE_PUBLIC_KEY"); + env::remove_var("LANGFUSE_SECRET_KEY"); + env::set_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY", "test_public_key"); + env::set_var("LANGFUSE_INIT_PROJECT_SECRET_KEY", "test_secret_key"); + assert!(langfuse_layer::create_langfuse_observer().is_some()); + + // Test fallback behavior + env::remove_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY"); + assert!(langfuse_layer::create_langfuse_observer().is_none()); + + // Restore original environment variables + for (var, value) in original_vars { + match value { + Some(val) => env::set_var(var, val), + None => env::remove_var(var), + } + } + } +} diff --git a/crates/goose-cli/src/main.rs b/crates/goose-cli/src/main.rs new file mode 100644 index 00000000..84f0a9ca --- /dev/null +++ b/crates/goose-cli/src/main.rs @@ -0,0 +1,234 @@ +use anyhow::Result; +use clap::{CommandFactory, Parser, Subcommand}; + +mod commands; +mod log_usage; +mod logging; +mod prompt; +mod session; + +use commands::agent_version::AgentCommand; +use commands::configure::handle_configure; +use commands::mcp::run_server; +use commands::session::build_session; +use commands::version::print_version; +use console::style; +use goose::config::Config; +use logging::setup_logging; +use std::io::{self, Read}; + +#[cfg(test)] +mod test_helpers; + +#[derive(Parser)] +#[command(author, about, long_about = None)] +struct Cli { + #[arg(short = 'v', long = "version")] + version: bool, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Configure Goose settings + #[command(about = "Configure Goose settings")] + Configure {}, + + /// Manage system prompts and behaviors + #[command(about = "Run one of the mcp servers bundled with goose")] + Mcp { name: String }, + + /// Start or resume interactive chat sessions + #[command(about = "Start or resume interactive chat sessions", alias = "s")] + Session { + /// Name for the chat session + #[arg( + short, + long, + value_name = "NAME", + help = "Name for the chat session (e.g., 'project-x')", + long_help = "Specify a name for your chat session. When used with --resume, will resume this specific session if it exists." + )] + name: Option, + + /// Resume a previous session + #[arg( + short, + long, + help = "Resume a previous session (last used or specified by --session)", + long_help = "Continue from a previous chat session. If --session is provided, resumes that specific session. Otherwise resumes the last used session." + )] + resume: bool, + + /// Add a stdio extension with environment variables and command + #[arg( + long = "with-extension", + value_name = "COMMAND", + help = "Add a stdio extension (e.g., 'GITHUB_TOKEN=xyz npx -y @modelcontextprotocol/server-github')", + long_help = "Add a stdio extension from a full command with environment variables. Format: 'ENV1=val1 ENV2=val2 command args...'" + )] + extension: Option, + + /// Add a builtin extension by name + #[arg( + long = "with-builtin", + value_name = "NAME", + help = "Add a builtin extension by name (e.g., 'developer')", + long_help = "Add a builtin extension that is bundled with goose by specifying its name" + )] + builtin: Option, + }, + + /// Execute commands from an instruction file + #[command(about = "Execute commands from an instruction file or stdin")] + Run { + /// Path to instruction file containing commands + #[arg( + short, + long, + value_name = "FILE", + help = "Path to instruction file containing commands", + conflicts_with = "input_text" + )] + instructions: Option, + + /// Input text containing commands + #[arg( + short = 't', + long = "text", + value_name = "TEXT", + help = "Input text to provide to Goose directly", + long_help = "Input text containing commands for Goose. Use this in lieu of the instructions argument.", + conflicts_with = "instructions" + )] + input_text: Option, + + /// Name for this run session + #[arg( + short, + long, + value_name = "NAME", + help = "Name for this run session (e.g., 'daily-tasks')", + long_help = "Specify a name for this run session. This helps identify and resume specific runs later." + )] + name: Option, + + /// Resume a previous run + #[arg( + short, + long, + action = clap::ArgAction::SetTrue, + help = "Resume from a previous run", + long_help = "Continue from a previous run, maintaining the execution state and context." + )] + resume: bool, + + /// Add a stdio extension with environment variables and command + #[arg( + long = "with-extension", + value_name = "COMMAND", + help = "Add a stdio extension with environment variables and command (e.g., 'GITHUB_TOKEN=xyz npx -y @modelcontextprotocol/server-github')", + long_help = "Add a stdio extension with environment variables and command. Format: 'ENV1=val1 ENV2=val2 command args...'" + )] + extension: Option, + + /// Add a builtin extension by name + #[arg( + long = "with-builtin", + value_name = "NAME", + help = "Add a builtin extension by name (e.g., 'developer')", + long_help = "Add a builtin extension that is compiled into goose by specifying its name" + )] + builtin: Option, + }, + + /// List available agent versions + Agents(AgentCommand), +} + +#[derive(clap::ValueEnum, Clone, Debug)] +enum CliProviderVariant { + OpenAi, + Databricks, + Ollama, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + if cli.version { + print_version(); + return Ok(()); + } + + match cli.command { + Some(Command::Configure {}) => { + let _ = handle_configure().await; + return Ok(()); + } + Some(Command::Mcp { name }) => { + let _ = run_server(&name).await; + } + Some(Command::Session { + name, + resume, + extension, + builtin, + }) => { + let mut session = build_session(name, resume, extension, builtin).await; + setup_logging(session.session_file().file_stem().and_then(|s| s.to_str()))?; + + let _ = session.start().await; + return Ok(()); + } + Some(Command::Run { + instructions, + input_text, + name, + resume, + extension, + builtin, + }) => { + // Validate that we have some input source + if instructions.is_none() && input_text.is_none() { + eprintln!("Error: Must provide either --instructions or --text"); + std::process::exit(1); + } + + let contents = if let Some(file_name) = instructions { + let file_path = std::path::Path::new(&file_name); + std::fs::read_to_string(file_path).expect("Failed to read the instruction file") + } else if let Some(input_text) = input_text { + input_text + } else { + let mut stdin = String::new(); + io::stdin() + .read_to_string(&mut stdin) + .expect("Failed to read from stdin"); + stdin + }; + let mut session = build_session(name, resume, extension, builtin).await; + let _ = session.headless_start(contents.clone()).await; + return Ok(()); + } + Some(Command::Agents(cmd)) => { + cmd.run()?; + return Ok(()); + } + None => { + Cli::command().print_help()?; + println!(); + if !Config::global().exists() { + println!( + "\n {}: Run '{}' to setup goose for the first time", + style("Tip").green().italic(), + style("goose configure").cyan() + ); + } + } + } + Ok(()) +} diff --git a/crates/goose-cli/src/prompt.rs b/crates/goose-cli/src/prompt.rs new file mode 100644 index 00000000..f2471edd --- /dev/null +++ b/crates/goose-cli/src/prompt.rs @@ -0,0 +1,38 @@ +use anyhow::Result; +use goose::message::Message; + +pub mod renderer; +pub mod rustyline; +pub mod thinking; + +pub trait Prompt { + fn render(&mut self, message: Box); + fn get_input(&mut self) -> Result; + fn show_busy(&mut self); + fn hide_busy(&self); + fn close(&self); + /// Load the user's message history into the prompt for command history navigation. First message is the oldest message. + /// When history is supported by the prompt. + fn load_user_message_history(&mut self, _messages: Vec) {} + fn goose_ready(&self) { + println!("\n"); + println!("Goose is running! Enter your instructions, or try asking what goose can do."); + println!("\n"); + } +} + +pub struct Input { + pub input_type: InputType, + pub content: Option, // Optional content as sometimes the user may be issuing a command eg. (Exit) +} + +pub enum InputType { + AskAgain, // Ask the user for input again. Control flow command. + Message, // User sent a message + Exit, // User wants to exit the session +} + +pub enum Theme { + Light, + Dark, +} diff --git a/crates/goose-cli/src/prompt/renderer.rs b/crates/goose-cli/src/prompt/renderer.rs new file mode 100644 index 00000000..0926ba0b --- /dev/null +++ b/crates/goose-cli/src/prompt/renderer.rs @@ -0,0 +1,407 @@ +use std::collections::HashMap; +use std::io::{self, Write}; +use std::path::PathBuf; + +use bat::WrappingMode; +use console::style; +use goose::message::{Message, MessageContent, ToolRequest, ToolResponse}; +use mcp_core::role::Role; +use mcp_core::{content::Content, tool::ToolCall}; +use serde_json::Value; + +use super::Theme; + +const MAX_STRING_LENGTH: usize = 40; +const MAX_PATH_LENGTH: usize = 60; +const INDENT: &str = " "; + +/// Shortens a path string by abbreviating directory names while keeping the last two components intact. +/// If the path starts with the user's home directory, it will be replaced with ~. +/// +/// # Examples +/// ``` +/// let path = "/Users/alice/Development/very/long/path/to/file.txt"; +/// assert_eq!( +/// shorten_path(path), +/// "~/D/v/l/p/to/file.txt" +/// ); +/// ``` +fn shorten_path(path: &str) -> String { + let path = PathBuf::from(path); + + // First try to convert to ~ if it's in home directory + let home = dirs::home_dir(); + let path_str = if let Some(home) = home { + if let Ok(stripped) = path.strip_prefix(home) { + format!("~/{}", stripped.display()) + } else { + path.display().to_string() + } + } else { + path.display().to_string() + }; + + // If path is already short enough, return as is + if path_str.len() <= MAX_PATH_LENGTH { + return path_str; + } + + let parts: Vec<_> = path_str.split('/').collect(); + + // If we have 3 or fewer parts, return as is + if parts.len() <= 3 { + return path_str; + } + + // Keep the first component (empty string before root / or ~) and last two components intact + let mut shortened = vec![parts[0].to_string()]; + + // Shorten middle components to their first letter + for component in &parts[1..parts.len() - 2] { + if !component.is_empty() { + shortened.push(component.chars().next().unwrap_or('?').to_string()); + } + } + + // Add the last two components + shortened.push(parts[parts.len() - 2].to_string()); + shortened.push(parts[parts.len() - 1].to_string()); + + shortened.join("/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_shorten_path() { + // Test a long path without home directory + let long_path = "/Users/test/Development/this/is/a/very/long/nested/deeply/example.txt"; + let shortened = shorten_path(long_path); + assert!( + shortened.len() < long_path.len(), + "Shortened path '{}' should be shorter than original '{}'", + shortened, + long_path + ); + assert!( + shortened.ends_with("deeply/example.txt"), + "Shortened path '{}' should end with 'deeply/example.txt'", + shortened + ); + + // Test a short path (shouldn't be modified) + assert_eq!(shorten_path("/usr/local/bin"), "/usr/local/bin"); + + // Test path with less than 3 components + assert_eq!(shorten_path("/usr/local"), "/usr/local"); + } +} + +/// Implement the ToolRenderer trait for each tool that you want to render in the prompt. +pub trait ToolRenderer: ToolRendererClone { + fn tool_name(&self) -> String; + fn request(&self, tool_request: &ToolRequest, theme: &str); + fn response(&self, tool_response: &ToolResponse, theme: &str); +} + +// Helper trait for cloning boxed ToolRenderer objects +pub trait ToolRendererClone { + fn clone_box(&self) -> Box; +} + +// Implement the helper trait for any type that implements ToolRenderer and Clone +impl ToolRendererClone for T +where + T: 'static + ToolRenderer + Clone, +{ + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +// Make Box clonable +impl Clone for Box { + fn clone(&self) -> Box { + self.clone_box() + } +} + +#[derive(Clone)] +pub struct DefaultRenderer; + +impl ToolRenderer for DefaultRenderer { + fn tool_name(&self) -> String { + "default".to_string() + } + + fn request(&self, tool_request: &ToolRequest, theme: &str) { + match &tool_request.tool_call { + Ok(call) => { + default_print_request_header(call); + + // Format and print the parameters + print_params(&call.arguments, 0); + print_newline(); + } + Err(e) => print_markdown(&e.to_string(), theme), + } + } + + fn response(&self, tool_response: &ToolResponse, theme: &str) { + default_response_renderer(tool_response, theme); + } +} + +#[derive(Clone)] +pub struct TextEditorRenderer; + +impl ToolRenderer for TextEditorRenderer { + fn tool_name(&self) -> String { + "developer__text_editor".to_string() + } + + fn request(&self, tool_request: &ToolRequest, theme: &str) { + match &tool_request.tool_call { + Ok(call) => { + default_print_request_header(call); + + // Print path first with special formatting + if let Some(Value::String(path)) = call.arguments.get("path") { + println!( + "{}: {}", + style("path").dim(), + style(shorten_path(path)).green() + ); + } + + // Print other arguments normally, excluding path + if let Some(args) = call.arguments.as_object() { + let mut other_args = serde_json::Map::new(); + for (k, v) in args { + if k != "path" { + other_args.insert(k.clone(), v.clone()); + } + } + print_params(&Value::Object(other_args), 0); + } + print_newline(); + } + Err(e) => print_markdown(&e.to_string(), theme), + } + } + + fn response(&self, tool_response: &ToolResponse, theme: &str) { + default_response_renderer(tool_response, theme); + } +} + +#[derive(Clone)] +pub struct BashDeveloperExtensionRenderer; + +impl ToolRenderer for BashDeveloperExtensionRenderer { + fn tool_name(&self) -> String { + "developer__shell".to_string() + } + + fn request(&self, tool_request: &ToolRequest, theme: &str) { + match &tool_request.tool_call { + Ok(call) => { + default_print_request_header(call); + + match call.arguments.get("command") { + Some(Value::String(s)) => { + println!("{}: {}", style("command").dim(), style(s).green()); + } + _ => print_params(&call.arguments, 0), + } + print_newline(); + } + Err(e) => print_markdown(&e.to_string(), theme), + } + } + + fn response(&self, tool_response: &ToolResponse, theme: &str) { + default_response_renderer(tool_response, theme); + } +} + +pub fn render(message: &Message, theme: &Theme, renderers: HashMap>) { + let theme = match theme { + Theme::Light => "GitHub", + Theme::Dark => "zenburn", + }; + + let mut last_tool_name: &str = "default"; + for message_content in &message.content { + match message_content { + MessageContent::Text(text) => print_markdown(&text.text, theme), + MessageContent::ToolRequest(tool_request) => match &tool_request.tool_call { + Ok(call) => { + last_tool_name = &call.name; + renderers + .get(&call.name) + .or_else(|| renderers.get("default")) + .unwrap() + .request(tool_request, theme); + } + Err(_) => renderers + .get("default") + .unwrap() + .request(tool_request, theme), + }, + MessageContent::ToolResponse(tool_response) => renderers + .get(last_tool_name) + .or_else(|| renderers.get("default")) + .unwrap() + .response(tool_response, theme), + MessageContent::Image(image) => { + println!("Image: [data: {}, type: {}]", image.data, image.mime_type); + } + } + } + + print_newline(); + io::stdout().flush().expect("Failed to flush stdout"); +} + +pub fn default_response_renderer(tool_response: &ToolResponse, theme: &str) { + match &tool_response.tool_result { + Ok(contents) => { + for content in contents { + if content + .audience() + .is_some_and(|audience| !audience.contains(&Role::User)) + { + continue; + } + + let min_priority = std::env::var("GOOSE_CLI_MIN_PRIORITY") + .ok() + .and_then(|val| val.parse::().ok()) + .unwrap_or(0.0); + + // if priority is not set OR less than or equal to min_priority, do not render + if content + .priority() + .is_some_and(|priority| priority <= min_priority) + || content.priority().is_none() + { + continue; + } + + if let Content::Text(text) = content { + print_markdown(&text.text, theme); + } + } + } + Err(e) => print_markdown(&e.to_string(), theme), + } +} + +pub fn default_print_request_header(call: &ToolCall) { + // Print the tool name with an emoji + + // use rsplit to handle any prefixed tools with more underscores + // unicode gets converted to underscores during sanitization + let parts: Vec<_> = call.name.rsplit("__").collect(); + + let tool_header = format!( + "─── {} | {} ──────────────────────────", + style(parts.first().unwrap_or(&"unknown")), + style( + parts + .split_first() + // client name is the rest of the split, reversed + // reverse the iterator and re-join on __ + .map(|(_, s)| s.iter().rev().copied().collect::>().join("__")) + .unwrap_or_else(|| "unknown".to_string()) + ) + .magenta() + .dim(), + ); + print_newline(); + println!("{}", tool_header); +} + +pub fn print_markdown(content: &str, theme: &str) { + bat::PrettyPrinter::new() + .input(bat::Input::from_bytes(content.as_bytes())) + .theme(theme) + .language("Markdown") + .wrapping_mode(WrappingMode::Character) + .print() + .unwrap(); +} + +/// Format and print parameters recursively with proper indentation and colors +pub fn print_params(value: &Value, depth: usize) { + let indent = INDENT.repeat(depth); + + match value { + Value::Object(map) => { + for (key, val) in map { + match val { + Value::Object(_) => { + println!("{}{}:", indent, style(key).dim()); + print_params(val, depth + 1); + } + Value::Array(arr) => { + println!("{}{}:", indent, style(key).dim()); + for item in arr.iter() { + println!("{}{}- ", indent, INDENT); + print_params(item, depth + 2); + } + } + Value::String(s) => { + if s.len() > MAX_STRING_LENGTH { + println!("{}{}: {}", indent, style(key).dim(), style("...").dim()); + } else { + println!("{}{}: {}", indent, style(key).dim(), style(s).green()); + } + } + Value::Number(n) => { + println!("{}{}: {}", indent, style(key).dim(), style(n).blue()); + } + Value::Bool(b) => { + println!("{}{}: {}", indent, style(key).dim(), style(b).blue()); + } + Value::Null => { + println!("{}{}: {}", indent, style(key).dim(), style("null").dim()); + } + } + } + } + Value::Array(arr) => { + for (i, item) in arr.iter().enumerate() { + println!("{}{}.", indent, i + 1); + print_params(item, depth + 1); + } + } + Value::String(s) => { + if s.len() > MAX_STRING_LENGTH { + println!( + "{}{}", + indent, + style(format!("[REDACTED: {} chars]", s.len())).yellow() + ); + } else { + println!("{}{}", indent, style(s).green()); + } + } + Value::Number(n) => { + println!("{}{}", indent, style(n).yellow()); + } + Value::Bool(b) => { + println!("{}{}", indent, style(b).yellow()); + } + Value::Null => { + println!("{}{}", indent, style("null").dim()); + } + } +} + +pub fn print_newline() { + println!(); +} diff --git a/crates/goose-cli/src/prompt/rustyline.rs b/crates/goose-cli/src/prompt/rustyline.rs new file mode 100644 index 00000000..bedba60b --- /dev/null +++ b/crates/goose-cli/src/prompt/rustyline.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; + +use super::{ + renderer::{ + render, BashDeveloperExtensionRenderer, DefaultRenderer, TextEditorRenderer, ToolRenderer, + }, + thinking::get_random_thinking_message, + Input, InputType, Prompt, Theme, +}; + +use anyhow::Result; +use cliclack::spinner; +use goose::message::Message; +use mcp_core::Role; +use rustyline::{DefaultEditor, EventHandler, KeyCode, KeyEvent, Modifiers}; + +const PROMPT: &str = "\x1b[1m\x1b[38;5;30m( O)> \x1b[0m"; + +pub struct RustylinePrompt { + spinner: cliclack::ProgressBar, + theme: Theme, + renderers: HashMap>, + editor: DefaultEditor, +} + +impl RustylinePrompt { + pub fn new() -> Self { + let mut renderers: HashMap> = HashMap::new(); + let default_renderer = DefaultRenderer; + renderers.insert(default_renderer.tool_name(), Box::new(default_renderer)); + + let bash_dev_extension_renderer = BashDeveloperExtensionRenderer; + renderers.insert( + bash_dev_extension_renderer.tool_name(), + Box::new(bash_dev_extension_renderer), + ); + + let text_editor_renderer = TextEditorRenderer; + renderers.insert( + text_editor_renderer.tool_name(), + Box::new(text_editor_renderer), + ); + + let mut editor = DefaultEditor::new().expect("Failed to create editor"); + editor.bind_sequence( + KeyEvent(KeyCode::Char('j'), Modifiers::CTRL), + EventHandler::Simple(rustyline::Cmd::Newline), + ); + + RustylinePrompt { + spinner: spinner(), + theme: std::env::var("GOOSE_CLI_THEME") + .ok() + .map(|val| { + if val.eq_ignore_ascii_case("light") { + Theme::Light + } else { + Theme::Dark + } + }) + .unwrap_or(Theme::Dark), + renderers, + editor, + } + } +} + +impl Prompt for RustylinePrompt { + fn render(&mut self, message: Box) { + render(&message, &self.theme, self.renderers.clone()); + } + + fn show_busy(&mut self) { + self.spinner = spinner(); + self.spinner + .start(format!("{}...", get_random_thinking_message())); + } + + fn hide_busy(&self) { + self.spinner.stop(""); + } + + fn get_input(&mut self) -> Result { + let input = self.editor.readline(PROMPT); + let mut message_text = match input { + Ok(text) => { + // Add valid input to history + if let Err(e) = self.editor.add_history_entry(text.as_str()) { + eprintln!("Failed to add to history: {}", e); + } + text + } + Err(e) => { + match e { + rustyline::error::ReadlineError::Interrupted => (), + _ => eprintln!("Input error: {}", e), + } + return Ok(Input { + input_type: InputType::Exit, + content: None, + }); + } + }; + message_text = message_text.trim().to_string(); + + if message_text.eq_ignore_ascii_case("/exit") + || message_text.eq_ignore_ascii_case("/quit") + || message_text.eq_ignore_ascii_case("exit") + || message_text.eq_ignore_ascii_case("quit") + { + Ok(Input { + input_type: InputType::Exit, + content: None, + }) + } else if message_text.eq_ignore_ascii_case("/t") { + self.theme = match self.theme { + Theme::Light => { + println!("Switching to Dark theme"); + Theme::Dark + } + Theme::Dark => { + println!("Switching to Light theme"); + Theme::Light + } + }; + return Ok(Input { + input_type: InputType::AskAgain, + content: None, + }); + } else if message_text.eq_ignore_ascii_case("/?") + || message_text.eq_ignore_ascii_case("/help") + { + println!("Commands:"); + println!("/exit - Exit the session"); + println!("/t - Toggle Light/Dark theme"); + println!("/? | /help - Display this help message"); + println!("Ctrl+C - Interrupt goose (resets the interaction to before the interrupted user request)"); + println!("Ctrl+j - Adds a newline"); + println!("Use Up/Down arrow keys to navigate through command history"); + return Ok(Input { + input_type: InputType::AskAgain, + content: None, + }); + } else { + return Ok(Input { + input_type: InputType::Message, + content: Some(message_text.to_string()), + }); + } + } + + fn load_user_message_history(&mut self, messages: Vec) { + for message in messages.into_iter().filter(|m| m.role == Role::User) { + for content in message.content { + if let Some(text) = content.as_text() { + if let Err(e) = self.editor.add_history_entry(text) { + eprintln!("Failed to add to history: {}", e); + } + } + } + } + } + + fn close(&self) { + // No cleanup required + } +} diff --git a/crates/goose-cli/src/prompt/thinking.rs b/crates/goose-cli/src/prompt/thinking.rs new file mode 100644 index 00000000..cc187522 --- /dev/null +++ b/crates/goose-cli/src/prompt/thinking.rs @@ -0,0 +1,225 @@ +use rand::seq::SliceRandom; + +/// Extended list of playful thinking messages including both goose and general AI actions +pub const THINKING_MESSAGES: &[&str] = &[ + "Thinking", + "Thinking hard", + // Include all goose actions + "Spreading wings", + "Honking thoughtfully", + "Waddling to conclusions", + "Flapping wings excitedly", + "Preening code feathers", + "Gathering digital breadcrumbs", + "Paddling through data", + "Migrating thoughts", + "Nesting ideas", + "Squawking calculations", + "Ruffling algorithmic feathers", + "Pecking at problems", + "Stretching webbed feet", + "Foraging for solutions", + "Grooming syntax", + "Building digital nest", + "Patrolling the codebase", + "Gosling about", + "Strutting with purpose", + "Diving for answers", + "Herding bytes", + "Molting old code", + "Swimming through streams", + "Goose-stepping through logic", + "Synchronizing flock algorithms", + "Navigating code marshes", + "Incubating brilliant ideas", + "Arranging feathers recursively", + "Gliding through branches", + "Migrating to better solutions", + "Nesting functions carefully", + "Hatching clever solutions", + "Preening parse trees", + "Flying through functions", + "Gathering syntax seeds", + "Webbing connections", + "Flocking to optimizations", + "Paddling through protocols", + "Honking success signals", + "Waddling through workflows", + "Nesting in neural networks", + // AI thinking actions + "Consulting the digital oracle", + "Summoning binary spirits", + "Reticulating splines", + "Calculating meaning of life", + "Traversing neural pathways", + "Untangling spaghetti code", + "Mining thought gems", + "Defragmenting brain bits", + "Compiling wisdom", + "Debugging reality", + "Optimizing thought processes", + "Scanning parallel universes", + "Reorganizing bits and bytes", + "Calibrating neural networks", + "Charging creativity cells", + "Indexing imagination", + "Parsing possibilities", + "Buffering brilliance", + "Loading clever responses", + "Generating witty remarks", + "Synthesizing solutions", + "Applying machine learning", + "Calculating quantum states", + "Analyzing algorithms", + "Decoding human intent", + "Exploring solution space", + "Gathering computational momentum", + "Initializing clever mode", + "Juggling variables", + "Knitting neural networks", + "Learning at light speed", + "Navigating knowledge graphs", + "Orchestrating outputs", + "Pondering possibilities", + "Reading between the lines", + "Searching solution space", + "Training thought vectors", + "Unfolding understanding", + "Validating variables", + "Weaving wisdom web", + "Yielding insights", + "Zooming through zettabytes", + "Baking fresh ideas", + "Charging creativity crystals", + "Dancing with data", + "Enchanting electrons", + "Folding thought origami", + "Growing solution trees", + "Harmonizing heuristics", + "Inspiring innovations", + "Jazzing up algorithms", + "Kindling knowledge", + "Levitating logic gates", + "Manifesting solutions", + "Nurturing neural nets", + "Optimizing outcomes", + "Painting with pixels", + "Questioning bits", + "Recycling random thoughts", + "Serenading semiconductors", + "Taming tensors", + "Unlocking understanding", + "Visualizing vectors", + "Wrangling widgets", + "Yodeling yaml", + "Aligning artificial awarenesses", + "Bootstrapping brain bytes", + "Contemplating code conundrums", + "Distilling digital dreams", + "Energizing electron engines", + "Fabricating future frameworks", + "Generating genius guidelines", + "Harmonizing hardware helpers", + "Illuminating input insights", + "Kindling knowledge kernels", + "Linking logical lattices", + "Materializing memory maps", + "Navigating neural nodes", + "Orchestrating output oracles", + "Pioneering program paths", + "Quantifying quantum queries", + "Refactoring reality routines", + "Synchronizing system states", + "Transforming thought threads", + "Unifying understanding units", + "Vectorizing virtual visions", + "Weaving wisdom wavelengths", + "Yielding yaml yearnings", + "Brewing binary brilliance", + "Crafting code crystals", + "Designing data dreams", + "Encoding ethereal elements", + "Filtering function flows", + "Gathering gigabyte galaxies", + "Hashing hope hypotheses", + "Igniting innovation ions", + "Joining joy journals", + "Knitting knowledge knots", + "Launching logic loops", + "Merging memory matrices", + "Nourishing neural networks", + "Ordering output orbits", + "Processing pattern particles", + "Rendering reality rays", + "Streaming syntax stars", + "Threading thought theories", + "Updating understanding units", + "Validating virtual vectors", + "Warming wisdom waves", + "Examining electron echoes", + "Yoking yesterday yields", + "Assembling algorithm arrays", + "Balancing binary bridges", + "Calculating cosmic codes", + "Debugging dream drivers", + "Encrypting ethereal edges", + "Formatting future frames", + "Growing gradient gardens", + "Harvesting hash harmonies", + "Importing insight ions", + "Keeping kernel keys", + "Linking lambda loops", + "Mapping memory mazes", + "Normalizing neural nodes", + "Organizing output oceans", + "Parsing pattern paths", + "Sampling syntax streams", + "Testing thought threads", + "Validating virtual vectors", + "Examining electron echoes", + "Accelerating abstract algebras", + "Buffering binary bubbles", + "Caching cosmic calculations", + "Deploying digital dreams", + "Evolving ethereal entities", + "Calculating response probabilities", + "Updating knowledge graphs", + "Processing neural feedback", + "Exploring decision trees", + "Measuring semantic distance", + "Connecting synaptic pathways", + "Evaluating response options", + "Scanning memory banks", + "Simulating future outcomes", + "Adjusting confidence weights", + "Mapping context vectors", + "Balancing response parameters", + "Running inference engines", + "Optimizing memory usage", + "Merging knowledge streams", + "Calibrating response tone", + "Analyzing input patterns", + "Processing feedback loops", + "Measuring response quality", + "Scanning information matrices", + "Processing user intent", + "Measuring response coherence", + "Exploring solution paths", + "Processing context clues", + "Scanning memory circuits", + "Building response chains", + "Analyzing conversation flow", + "Processing temporal data", + "Exploring concept spaces", + "Processing memory streams", + "Evaluating logical paths", + "Building thought graphs", + "Scanning neural pathways", +]; + +/// Returns a random thinking message from the extended list +pub fn get_random_thinking_message() -> &'static str { + THINKING_MESSAGES + .choose(&mut rand::thread_rng()) + .unwrap_or(&THINKING_MESSAGES[0]) +} diff --git a/crates/goose-cli/src/session.rs b/crates/goose-cli/src/session.rs new file mode 100644 index 00000000..7b7d9644 --- /dev/null +++ b/crates/goose-cli/src/session.rs @@ -0,0 +1,331 @@ +use anyhow::Result; +use core::panic; +use futures::StreamExt; +use std::fs::{self, File}; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +use crate::log_usage::log_usage; +use crate::prompt::{InputType, Prompt}; +use goose::agents::Agent; +use goose::message::{Message, MessageContent}; +use mcp_core::handler::ToolError; +use mcp_core::role::Role; + +// File management functions +pub fn ensure_session_dir() -> Result { + let home_dir = dirs::home_dir().ok_or(anyhow::anyhow!("Could not determine home directory"))?; + let config_dir = home_dir.join(".config").join("goose").join("sessions"); + + if !config_dir.exists() { + fs::create_dir_all(&config_dir)?; + } + + Ok(config_dir) +} + +pub fn get_most_recent_session() -> Result { + let session_dir = ensure_session_dir()?; + let mut entries = fs::read_dir(&session_dir)? + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl")) + .collect::>(); + + if entries.is_empty() { + return Err(anyhow::anyhow!("No session files found")); + } + + // Sort by modification time, most recent first + entries.sort_by(|a, b| { + b.metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + .cmp( + &a.metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH), + ) + }); + + Ok(entries[0].path()) +} + +pub fn readable_session_file(session_file: &PathBuf) -> Result { + match fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(session_file) + { + Ok(file) => Ok(file), + Err(e) => Err(anyhow::anyhow!("Failed to open session file: {}", e)), + } +} + +pub fn persist_messages(session_file: &PathBuf, messages: &[Message]) -> Result<()> { + let file = fs::File::create(session_file)?; // Create or truncate the file + persist_messages_internal(file, messages) +} + +fn persist_messages_internal(session_file: File, messages: &[Message]) -> Result<()> { + let mut writer = std::io::BufWriter::new(session_file); + + for message in messages { + serde_json::to_writer(&mut writer, &message)?; + writeln!(writer)?; + } + + writer.flush()?; + Ok(()) +} + +pub fn deserialize_messages(file: File) -> Result> { + let reader = io::BufReader::new(file); + let mut messages = Vec::new(); + + for line in reader.lines() { + messages.push(serde_json::from_str::(&line?)?); + } + + Ok(messages) +} + +// Session management +pub struct Session<'a> { + agent: Box, + prompt: Box, + session_file: PathBuf, + messages: Vec, +} + +#[allow(dead_code)] +impl<'a> Session<'a> { + pub fn new( + agent: Box, + mut prompt: Box, + session_file: PathBuf, + ) -> Self { + let messages = match readable_session_file(&session_file) { + Ok(file) => deserialize_messages(file).unwrap_or_else(|e| { + eprintln!( + "Failed to read messages from session file. Starting fresh.\n{}", + e + ); + Vec::::new() + }), + Err(e) => { + eprintln!("Failed to load session file. Starting fresh.\n{}", e); + Vec::::new() + } + }; + + prompt.load_user_message_history(messages.clone()); + + Session { + agent, + prompt, + session_file, + messages, + } + } + + pub async fn start(&mut self) -> Result<(), Box> { + self.prompt.goose_ready(); + + loop { + let input = self.prompt.get_input().unwrap(); + match input.input_type { + InputType::Message => { + if let Some(content) = &input.content { + self.messages.push(Message::user().with_text(content)); + persist_messages(&self.session_file, &self.messages)?; + } + } + InputType::Exit => break, + InputType::AskAgain => continue, + } + + self.prompt.show_busy(); + self.agent_process_messages().await; + self.prompt.hide_busy(); + } + self.close_session().await; + Ok(()) + } + + pub async fn headless_start( + &mut self, + initial_message: String, + ) -> Result<(), Box> { + self.messages + .push(Message::user().with_text(initial_message.as_str())); + persist_messages(&self.session_file, &self.messages)?; + + self.agent_process_messages().await; + + self.close_session().await; + Ok(()) + } + + async fn agent_process_messages(&mut self) { + let mut stream = match self.agent.reply(&self.messages).await { + Ok(stream) => stream, + Err(e) => { + eprintln!("Error starting reply stream: {}", e); + return; + } + }; + loop { + tokio::select! { + response = stream.next() => { + match response { + Some(Ok(message)) => { + self.messages.push(message.clone()); + persist_messages(&self.session_file, &self.messages).unwrap_or_else(|e| eprintln!("Failed to persist messages: {}", e)); + self.prompt.hide_busy(); + self.prompt.render(Box::new(message.clone())); + self.prompt.show_busy(); + } + Some(Err(e)) => { + eprintln!("Error: {}", e); + drop(stream); + self.rewind_messages(); + self.prompt.render(raw_message(r#" +The error above was an exception we were not able to handle.\n\n +These errors are often related to connection or authentication\n +We've removed the conversation up to the most recent user message + - depending on the error you may be able to continue"#)); + break; + } + None => break, + } + } + _ = tokio::signal::ctrl_c() => { + // Kill any running processes when the client disconnects + // TODO is this used? I suspect post MCP this is on the server instead + // goose::process_store::kill_processes(); + drop(stream); + self.handle_interrupted_messages(); + break; + } + } + } + } + + /// Rewind the messages to before the last user message (they have cancelled it). + fn rewind_messages(&mut self) { + if self.messages.is_empty() { + return; + } + + // Remove messages until we find the last user 'Text' message (not a tool response). + while let Some(message) = self.messages.last() { + if message.role == Role::User + && message + .content + .iter() + .any(|c| matches!(c, MessageContent::Text(_))) + { + break; + } + self.messages.pop(); + } + + // Remove the last user text message we found. + if !self.messages.is_empty() { + self.messages.pop(); + } + } + + fn handle_interrupted_messages(&mut self) { + // First, get any tool requests from the last message if it exists + let tool_requests = self + .messages + .last() + .filter(|msg| msg.role == Role::Assistant) + .map_or(Vec::new(), |msg| { + msg.content + .iter() + .filter_map(|content| { + if let MessageContent::ToolRequest(req) = content { + Some((req.id.clone(), req.tool_call.clone())) + } else { + None + } + }) + .collect() + }); + + if !tool_requests.is_empty() { + // Interrupted during a tool request + // Create tool responses for all interrupted tool requests + let mut response_message = Message::user(); + let last_tool_name = tool_requests + .last() + .and_then(|(_, tool_call)| tool_call.as_ref().ok().map(|tool| tool.name.clone())) + .unwrap_or_else(|| "tool".to_string()); + + for (req_id, _) in &tool_requests { + response_message.content.push(MessageContent::tool_response( + req_id.clone(), + Err(ToolError::ExecutionError( + "Interrupted by the user to make a correction".to_string(), + )), + )); + } + self.messages.push(response_message); + + let prompt_response = &format!( + "We interrupted the existing call to {}. How would you like to proceed?", + last_tool_name + ); + self.messages + .push(Message::assistant().with_text(prompt_response)); + self.prompt.render(raw_message(prompt_response)); + } else { + // An interruption occurred outside of a tool request-response. + if let Some(last_msg) = self.messages.last() { + if last_msg.role == Role::User { + match last_msg.content.first() { + Some(MessageContent::ToolResponse(_)) => { + // Interruption occurred after a tool had completed but not assistant reply + let prompt_response = "We interrupted the existing calls to tools. How would you like to proceed?"; + self.messages + .push(Message::assistant().with_text(prompt_response)); + self.prompt.render(raw_message(prompt_response)); + } + Some(_) => { + // A real users message + self.messages.pop(); + let prompt_response = "We interrupted before the model replied and removed the last message."; + self.prompt.render(raw_message(prompt_response)); + } + None => panic!("No content in last message"), + } + } + } + } + } + + async fn close_session(&mut self) { + self.prompt.render(raw_message( + format!( + "Closing session. Recorded to {}\n", + self.session_file.display() + ) + .as_str(), + )); + self.prompt.close(); + let usage = self.agent.usage().await; + log_usage(self.session_file.to_string_lossy().to_string(), usage); + } + + pub fn session_file(&self) -> PathBuf { + self.session_file.clone() + } +} + +fn raw_message(content: &str) -> Box { + Box::new(Message::assistant().with_text(content)) +} diff --git a/crates/goose-cli/src/test_helpers.rs b/crates/goose-cli/src/test_helpers.rs new file mode 100644 index 00000000..d1154200 --- /dev/null +++ b/crates/goose-cli/src/test_helpers.rs @@ -0,0 +1,70 @@ +/// Helper function to set up a temporary home directory for testing, returns path of that temp dir. +/// Also creates a default profiles.json to avoid obscure test failures when there are no profiles. +#[cfg(test)] +pub fn run_with_tmp_dir T, T>(func: F) -> T { + use std::ffi::OsStr; + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let temp_dir_path = temp_dir.path().to_path_buf(); + setup_profile(&temp_dir_path, None); + + temp_env::with_vars( + [ + ("HOME", Some(temp_dir_path.as_os_str())), + ("DATABRICKS_HOST", Some(OsStr::new("tmp_host_url"))), + ], + func, + ) +} + +#[cfg(test)] +#[allow(dead_code)] +pub async fn run_with_tmp_dir_async(func: F) -> T +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + use std::ffi::OsStr; + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let temp_dir_path = temp_dir.path().to_path_buf(); + setup_profile(&temp_dir_path, None); + + temp_env::async_with_vars( + [ + ("HOME", Some(temp_dir_path.as_os_str())), + ("DATABRICKS_HOST", Some(OsStr::new("tmp_host_url"))), + ], + func(), + ) + .await +} + +#[cfg(test)] +use std::path::Path; + +#[cfg(test)] +/// Setup a goose profile for testing, and an optional profile string +fn setup_profile(temp_dir_path: &Path, profile_string: Option<&str>) { + use std::fs; + + let profile_path = temp_dir_path + .join(".config") + .join("goose") + .join("profiles.json"); + fs::create_dir_all(profile_path.parent().unwrap()).unwrap(); + let default_profile = r#" +{ + "profile_items": { + "default": { + "provider": "databricks", + "model": "claude-3-5-sonnet-2", + "additional_extensions": [] + } + } +}"#; + + fs::write(&profile_path, profile_string.unwrap_or(default_profile)).unwrap(); +} diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml new file mode 100644 index 00000000..7e3de36f --- /dev/null +++ b/crates/goose-mcp/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "goose-mcp" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description.workspace = true + +[dependencies] +mcp-core = { path = "../mcp-core" } +mcp-server = { path = "../mcp-server" } +anyhow = "1.0.94" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" +url = "2.5" +urlencoding = "2.1.3" +base64 = "0.21" +thiserror = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +lazy_static = "1.5" +kill_tree = "0.2.4" +shellexpand = "3.1.0" +indoc = "2.0.5" +xcap = "0.0.14" +reqwest = { version = "0.11", features = ["json"] } +async-trait = "0.1" +chrono = { version = "0.4.38", features = ["serde"] } +dirs = "5.0.1" +tempfile = "3.8" +include_dir = "0.7.4" +google-drive3 = "6.0.0" +webbrowser = "0.8" +http-body-util = "0.1.2" +regex = "1.11.1" + +[dev-dependencies] +serial_test = "3.0.0" +sysinfo = "0.32.1" \ No newline at end of file diff --git a/crates/goose-mcp/README.md b/crates/goose-mcp/README.md new file mode 100644 index 00000000..822c1742 --- /dev/null +++ b/crates/goose-mcp/README.md @@ -0,0 +1,9 @@ +### Test with MCP Inspector + +Update examples/mcp.rs to use the appropriate the MCP server (eg. DeveloperRouter) + +```bash +npx @modelcontextprotocol/inspector cargo run -p goose-mcp --example mcp +``` + +Then visit the Inspector in the browser window and test the different endpoints. diff --git a/crates/goose-mcp/examples/mcp.rs b/crates/goose-mcp/examples/mcp.rs new file mode 100644 index 00000000..052e7857 --- /dev/null +++ b/crates/goose-mcp/examples/mcp.rs @@ -0,0 +1,36 @@ +// An example script to run an MCP server +use anyhow::Result; +use goose_mcp::MemoryRouter; +use mcp_server::router::RouterService; +use mcp_server::{ByteTransport, Server}; +use tokio::io::{stdin, stdout}; +use tracing_appender::rolling::{RollingFileAppender, Rotation}; +use tracing_subscriber::{self, EnvFilter}; + +#[tokio::main] +async fn main() -> Result<()> { + // Set up file appender for logging + let file_appender = RollingFileAppender::new(Rotation::DAILY, "logs", "goose-mcp-example.log"); + + // Initialize the tracing subscriber with file and stdout logging + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) + .with_writer(file_appender) + .with_target(false) + .with_thread_ids(true) + .with_file(true) + .with_line_number(true) + .init(); + + tracing::info!("Starting MCP server"); + + // Create an instance of our counter router + let router = RouterService(MemoryRouter::new()); + + // Create and run the server + let server = Server::new(router); + let transport = ByteTransport::new(stdin(), stdout()); + + tracing::info!("Server initialized and ready to handle requests"); + Ok(server.run(transport).await?) +} diff --git a/crates/goose-mcp/src/developer/lang.rs b/crates/goose-mcp/src/developer/lang.rs new file mode 100644 index 00000000..4d5609de --- /dev/null +++ b/crates/goose-mcp/src/developer/lang.rs @@ -0,0 +1,34 @@ +use std::path::Path; + +/// Get the markdown language identifier for a file extension +pub fn get_language_identifier(path: &Path) -> &'static str { + match path.extension().and_then(|ext| ext.to_str()) { + Some("rs") => "rust", + Some("py") => "python", + Some("js") => "javascript", + Some("ts") => "typescript", + Some("json") => "json", + Some("toml") => "toml", + Some("yaml") | Some("yml") => "yaml", + Some("sh") => "bash", + Some("go") => "go", + Some("md") => "markdown", + Some("html") => "html", + Some("css") => "css", + Some("sql") => "sql", + Some("java") => "java", + Some("cpp") | Some("cc") | Some("cxx") => "cpp", + Some("c") => "c", + Some("h") | Some("hpp") => "cpp", + Some("rb") => "ruby", + Some("php") => "php", + Some("swift") => "swift", + Some("kt") | Some("kts") => "kotlin", + Some("scala") => "scala", + Some("r") => "r", + Some("m") => "matlab", + Some("pl") => "perl", + Some("dockerfile") => "dockerfile", + _ => "", + } +} diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs new file mode 100644 index 00000000..0c09e20c --- /dev/null +++ b/crates/goose-mcp/src/developer/mod.rs @@ -0,0 +1,1006 @@ +mod lang; + +use anyhow::Result; +use base64::Engine; +use indoc::formatdoc; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + future::Future, + io::Cursor, + path::{Path, PathBuf}, + pin::Pin, +}; +use tokio::process::Command; +use url::Url; + +use mcp_core::{ + handler::{ResourceError, ToolError}, + protocol::ServerCapabilities, + resource::Resource, + tool::Tool, +}; +use mcp_server::router::CapabilitiesBuilder; +use mcp_server::Router; + +use mcp_core::content::Content; +use mcp_core::role::Role; + +use indoc::indoc; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use xcap::{Monitor, Window}; + +pub struct DeveloperRouter { + tools: Vec, + file_history: Arc>>>, + instructions: String, +} + +impl Default for DeveloperRouter { + fn default() -> Self { + Self::new() + } +} + +impl DeveloperRouter { + pub fn new() -> Self { + // TODO consider rust native search tools, we could use + // https://docs.rs/ignore/latest/ignore/ + + let bash_tool = Tool::new( + "shell".to_string(), + indoc! {r#" + Execute a command in the shell. + + This will return the output and error concatenated into a single string, as + you would see from running on the command line. There will also be an indication + of if the command succeeded or failed. + + Avoid commands that produce a large amount of ouput, and consider piping those outputs to files. + If you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that + this tool does not run indefinitely. + + **Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions + may show ignored or hidden files. For example *do not* use `find` or `ls -r` + - To locate a file by name: `rg --files | rg example.py` + - To locate consent inside files: `rg 'class Example'` + "#}.to_string(), + json!({ + "type": "object", + "required": ["command"], + "properties": { + "command": {"type": "string"} + } + }), + ); + + let text_editor_tool = Tool::new( + "text_editor".to_string(), + indoc! {r#" + Perform text editing operations on files. + + The `command` parameter specifies the operation to perform. Allowed options are: + - `view`: View the content of a file. + - `write`: Create or overwrite a file with the given content + - `str_replace`: Replace a string in a file with a new string. + - `undo_edit`: Undo the last edit made to a file. + + To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with + existing files! This is a full overwrite, so you must include everything - not just sections you are modifying. + + To use the str_replace command, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one + unique section of the original file, including any whitespace. Make sure to include enough context that the match is not + ambiguous. The entire original string will be replaced with `new_str`. + "#}.to_string(), + json!({ + "type": "object", + "required": ["command", "path"], + "properties": { + "path": { + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.", + "type": "string" + }, + "command": { + "type": "string", + "enum": ["view", "write", "str_replace", "undo_edit"], + "description": "Allowed options are: `view`, `write`, `str_replace`, undo_edit`." + }, + "old_str": {"type": "string"}, + "new_str": {"type": "string"}, + "file_text": {"type": "string"} + } + }), + ); + + let list_windows_tool = Tool::new( + "list_windows", + indoc! {r#" + List all available window titles that can be used with screen_capture. + Returns a list of window titles that can be used with the window_title parameter + of the screen_capture tool. + "#}, + json!({ + "type": "object", + "required": [], + "properties": {} + }), + ); + + let screen_capture_tool = Tool::new( + "screen_capture", + indoc! {r#" + Capture a screenshot of a specified display or window. + You can capture either: + 1. A full display (monitor) using the display parameter + 2. A specific window by its title using the window_title parameter + + Only one of display or window_title should be specified. + "#}, + json!({ + "type": "object", + "required": [], + "properties": { + "display": { + "type": "integer", + "default": 0, + "description": "The display number to capture (0 is main display)" + }, + "window_title": { + "type": "string", + "default": null, + "description": "Optional: the exact title of the window to capture. use the list_windows tool to find the available windows." + } + } + }), + ); + + // Get base instructions and working directory + let cwd = std::env::current_dir().expect("should have a current working dir"); + let base_instructions = formatdoc! {r#" + The developer extension gives you the capabilities to edit code files and run shell commands, + and can be used to solve a wide range of problems. + + You can use the shell tool to run any command that would work on the relevant operating system. + Use the shell tool as needed to locate files or interact with the project. + + Your windows/screen tools can be used for visual debugging. You should not use these tools unless + prompted to, but you can mention they are available if they are relevant. + + operating system: {os} + current directory: {cwd} + + "#, + os=std::env::consts::OS, + cwd=cwd.to_string_lossy(), + }; + + // Check for and read .goosehints file if it exists + let hints_path = cwd.join(".goosehints"); + let instructions = if hints_path.is_file() { + if let Ok(hints) = std::fs::read_to_string(&hints_path) { + format!("{base_instructions}\n### Project Hints\nThe developer extension includes some hints for working on the project in this directory.\n{hints}") + } else { + base_instructions + } + } else { + base_instructions + }; + + Self { + tools: vec![ + bash_tool, + text_editor_tool, + list_windows_tool, + screen_capture_tool, + ], + file_history: Arc::new(Mutex::new(HashMap::new())), + instructions, + } + } + + // Helper method to resolve a path relative to cwd + fn resolve_path(&self, path_str: &str) -> Result { + let cwd = std::env::current_dir().expect("should have a current working dir"); + let expanded = shellexpand::tilde(path_str); + let path = Path::new(expanded.as_ref()); + + let suggestion = cwd.join(path); + + match path.is_absolute() { + true => Ok(path.to_path_buf()), + false => Err(ToolError::InvalidParameters(format!( + "The path {} is not an absolute path, did you possibly mean {}?", + path_str, + suggestion.to_string_lossy(), + ))), + } + } + + // Implement bash tool functionality + async fn bash(&self, params: Value) -> Result, ToolError> { + let command = + params + .get("command") + .and_then(|v| v.as_str()) + .ok_or(ToolError::InvalidParameters( + "The command string is required".to_string(), + ))?; + + // TODO consider command suggestions and safety rails + + // TODO be more careful about backgrounding, revisit interleave + // Redirect stderr to stdout to interleave outputs + let cmd_with_redirect = format!("{} 2>&1", command); + + // Execute the command + let child = Command::new("bash") + .stdout(Stdio::piped()) // These two pipes required to capture output later. + .stderr(Stdio::piped()) + .stdin(Stdio::null()) + .kill_on_drop(true) // Critical so that the command is killed when the agent.reply stream is interrupted. + .arg("-c") + .arg(cmd_with_redirect) + .spawn() + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + // Wait for the command to complete and get output + let output = child + .wait_with_output() + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let output_str = String::from_utf8_lossy(&output.stdout); + Ok(vec![ + Content::text(output_str.clone()).with_audience(vec![Role::Assistant]), + Content::text(output_str) + .with_audience(vec![Role::User]) + .with_priority(0.0), + ]) + } + + async fn text_editor(&self, params: Value) -> Result, ToolError> { + let command = params + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'command' parameter".to_string()) + })?; + + let path_str = params + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'path' parameter".into()))?; + + let path = self.resolve_path(path_str)?; + + match command { + "view" => self.text_editor_view(&path).await, + "write" => { + let file_text = params + .get("file_text") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'file_text' parameter".into()) + })?; + + self.text_editor_write(&path, file_text).await + } + "str_replace" => { + let old_str = params + .get("old_str") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'old_str' parameter".into()) + })?; + let new_str = params + .get("new_str") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("Missing 'new_str' parameter".into()) + })?; + + self.text_editor_replace(&path, old_str, new_str).await + } + "undo_edit" => self.text_editor_undo(&path).await, + _ => Err(ToolError::InvalidParameters(format!( + "Unknown command '{}'", + command + ))), + } + } + + async fn text_editor_view(&self, path: &PathBuf) -> Result, ToolError> { + if path.is_file() { + // Check file size first (2MB limit) + const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024; // 2MB in bytes + const MAX_CHAR_COUNT: usize = 1 << 20; // 2^20 characters (1,048,576) + + let file_size = std::fs::metadata(path) + .map_err(|e| { + ToolError::ExecutionError(format!("Failed to get file metadata: {}", e)) + })? + .len(); + + if file_size > MAX_FILE_SIZE { + return Err(ToolError::ExecutionError(format!( + "File '{}' is too large ({:.2}MB). Maximum size is 2MB to prevent memory issues.", + path.display(), + file_size as f64 / 1024.0 / 1024.0 + ))); + } + + let uri = Url::from_file_path(path) + .map_err(|_| ToolError::ExecutionError("Invalid file path".into()))? + .to_string(); + + let content = std::fs::read_to_string(path) + .map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?; + + let char_count = content.chars().count(); + if char_count > MAX_CHAR_COUNT { + return Err(ToolError::ExecutionError(format!( + "File '{}' has too many characters ({}). Maximum character count is {}.", + path.display(), + char_count, + MAX_CHAR_COUNT + ))); + } + + let language = lang::get_language_identifier(path); + let formatted = formatdoc! {" + ### {path} + ```{language} + {content} + ``` + ", + path=path.display(), + language=language, + content=content, + }; + + // The LLM gets just a quick update as we expect the file to view in the status + // but we send a low priority message for the human + Ok(vec![ + Content::embedded_text(uri, content).with_audience(vec![Role::Assistant]), + Content::text(formatted) + .with_audience(vec![Role::User]) + .with_priority(0.0), + ]) + } else { + Err(ToolError::ExecutionError(format!( + "The path '{}' does not exist or is not a file.", + path.display() + ))) + } + } + + async fn text_editor_write( + &self, + path: &PathBuf, + file_text: &str, + ) -> Result, ToolError> { + // Write to the file + std::fs::write(path, file_text) + .map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?; + + // Try to detect the language from the file extension + let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + + // The assistant output does not show the file again because the content is already in the tool request + // but we do show it to the user here + Ok(vec![ + Content::text(format!("Successfully wrote to {}", path.display())) + .with_audience(vec![Role::Assistant]), + Content::text(formatdoc! {r#" + ### {path} + ```{language} + {content} + ``` + "#, + path=path.display(), + language=language, + content=file_text, + }) + .with_audience(vec![Role::User]) + .with_priority(0.2), + ]) + } + + async fn text_editor_replace( + &self, + path: &PathBuf, + old_str: &str, + new_str: &str, + ) -> Result, ToolError> { + // Check if file exists and is active + if !path.exists() { + return Err(ToolError::InvalidParameters(format!( + "File '{}' does not exist, you can write a new file with the `write` command", + path.display() + ))); + } + + // Read content + let content = std::fs::read_to_string(path) + .map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))?; + + // Ensure 'old_str' appears exactly once + if content.matches(old_str).count() > 1 { + return Err(ToolError::InvalidParameters( + "'old_str' must appear exactly once in the file, but it appears multiple times" + .into(), + )); + } + if content.matches(old_str).count() == 0 { + return Err(ToolError::InvalidParameters( + "'old_str' must appear exactly once in the file, but it does not appear in the file. Make sure the string exactly matches existing file content, including whitespace!".into(), + )); + } + + // Save history for undo + self.save_file_history(path)?; + + // Replace and write back + let new_content = content.replace(old_str, new_str); + std::fs::write(path, &new_content) + .map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?; + + // Try to detect the language from the file extension + let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + + // Show a snippet of the changed content with context + const SNIPPET_LINES: usize = 4; + + // Count newlines before the replacement to find the line number + let replacement_line = content + .split(old_str) + .next() + .expect("should split on already matched content") + .matches('\n') + .count(); + + // Calculate start and end lines for the snippet + let start_line = replacement_line.saturating_sub(SNIPPET_LINES); + let end_line = replacement_line + SNIPPET_LINES + new_str.matches('\n').count(); + + // Get the relevant lines for our snippet + let lines: Vec<&str> = new_content.lines().collect(); + let snippet = lines + .iter() + .skip(start_line) + .take(end_line - start_line + 1) + .cloned() + .collect::>() + .join("\n"); + + let output = formatdoc! {r#" + ```{language} + {snippet} + ``` + "#, + language=language, + snippet=snippet + }; + + let success_message = formatdoc! {r#" + The file {} has been edited, and the section now reads: + {} + Review the changes above for errors. Undo and edit the file again if necessary! + "#, + path.display(), + output + }; + + Ok(vec![ + Content::text(success_message).with_audience(vec![Role::Assistant]), + Content::text(output) + .with_audience(vec![Role::User]) + .with_priority(0.2), + ]) + } + + async fn text_editor_undo(&self, path: &PathBuf) -> Result, ToolError> { + let mut history = self.file_history.lock().unwrap(); + if let Some(contents) = history.get_mut(path) { + if let Some(previous_content) = contents.pop() { + // Write previous content back to file + std::fs::write(path, previous_content).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write file: {}", e)) + })?; + Ok(vec![Content::text("Undid the last edit")]) + } else { + Err(ToolError::InvalidParameters( + "No edit history available to undo".into(), + )) + } + } else { + Err(ToolError::InvalidParameters( + "No edit history available to undo".into(), + )) + } + } + + fn save_file_history(&self, path: &PathBuf) -> Result<(), ToolError> { + let mut history = self.file_history.lock().unwrap(); + let content = if path.exists() { + std::fs::read_to_string(path) + .map_err(|e| ToolError::ExecutionError(format!("Failed to read file: {}", e)))? + } else { + String::new() + }; + history.entry(path.clone()).or_default().push(content); + Ok(()) + } + + async fn list_windows(&self, _params: Value) -> Result, ToolError> { + let windows = Window::all() + .map_err(|_| ToolError::ExecutionError("Failed to list windows".into()))?; + + let window_titles: Vec = + windows.into_iter().map(|w| w.title().to_string()).collect(); + + Ok(vec![ + Content::text(format!("Available windows:\n{}", window_titles.join("\n"))) + .with_audience(vec![Role::Assistant]), + Content::text(format!("Available windows:\n{}", window_titles.join("\n"))) + .with_audience(vec![Role::User]) + .with_priority(0.0), + ]) + } + + async fn screen_capture(&self, params: Value) -> Result, ToolError> { + let mut image = if let Some(window_title) = + params.get("window_title").and_then(|v| v.as_str()) + { + // Try to find and capture the specified window + let windows = Window::all() + .map_err(|_| ToolError::ExecutionError("Failed to list windows".into()))?; + + let window = windows + .into_iter() + .find(|w| w.title() == window_title) + .ok_or_else(|| { + ToolError::ExecutionError(format!( + "No window found with title '{}'", + window_title + )) + })?; + + window.capture_image().map_err(|e| { + ToolError::ExecutionError(format!( + "Failed to capture window '{}': {}", + window_title, e + )) + })? + } else { + // Default to display capture if no window title is specified + let display = params.get("display").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + + let monitors = Monitor::all() + .map_err(|_| ToolError::ExecutionError("Failed to access monitors".into()))?; + let monitor = monitors.get(display).ok_or_else(|| { + ToolError::ExecutionError(format!( + "{} was not an available monitor, {} found.", + display, + monitors.len() + )) + })?; + + monitor.capture_image().map_err(|e| { + ToolError::ExecutionError(format!("Failed to capture display {}: {}", display, e)) + })? + }; + + // Resize the image to a reasonable width while maintaining aspect ratio + let max_width = 768; + if image.width() > max_width { + let scale = max_width as f32 / image.width() as f32; + let new_height = (image.height() as f32 * scale) as u32; + image = xcap::image::imageops::resize( + &image, + max_width, + new_height, + xcap::image::imageops::FilterType::Lanczos3, + ) + }; + + let mut bytes: Vec = Vec::new(); + image + .write_to(&mut Cursor::new(&mut bytes), xcap::image::ImageFormat::Png) + .map_err(|e| { + ToolError::ExecutionError(format!("Failed to write image buffer {}", e)) + })?; + + // Convert to base64 + let data = base64::prelude::BASE64_STANDARD.encode(bytes); + + Ok(vec![ + Content::text("Screenshot captured").with_audience(vec![Role::Assistant]), + Content::image(data, "image/png").with_priority(0.0), + ]) + } +} + +impl Router for DeveloperRouter { + fn name(&self) -> String { + "developer".to_string() + } + + fn instructions(&self) -> String { + self.instructions.clone() + } + + fn capabilities(&self) -> ServerCapabilities { + CapabilitiesBuilder::new().with_tools(false).build() + } + + fn list_tools(&self) -> Vec { + self.tools.clone() + } + + fn call_tool( + &self, + tool_name: &str, + arguments: Value, + ) -> Pin, ToolError>> + Send + 'static>> { + let this = self.clone(); + let tool_name = tool_name.to_string(); + Box::pin(async move { + match tool_name.as_str() { + "shell" => this.bash(arguments).await, + "text_editor" => this.text_editor(arguments).await, + "list_windows" => this.list_windows(arguments).await, + "screen_capture" => this.screen_capture(arguments).await, + _ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))), + } + }) + } + + // TODO see if we can make it easy to skip implementing these + fn list_resources(&self) -> Vec { + Vec::new() + } + + fn read_resource( + &self, + _uri: &str, + ) -> Pin> + Send + 'static>> { + Box::pin(async move { Ok("".to_string()) }) + } +} + +impl Clone for DeveloperRouter { + fn clone(&self) -> Self { + Self { + tools: self.tools.clone(), + file_history: Arc::clone(&self.file_history), + instructions: self.instructions.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serial_test::serial; + use std::fs; + use tempfile::TempDir; + use tokio::sync::OnceCell; + + #[test] + #[serial] + fn test_goosehints_when_present() { + let dir = TempDir::new().unwrap(); + std::env::set_current_dir(dir.path()).unwrap(); + + fs::write(".goosehints", "Test hint content").unwrap(); + let router = DeveloperRouter::new(); + let instructions = router.instructions(); + + assert!(instructions.contains("Test hint content")); + } + + #[test] + #[serial] + fn test_goosehints_when_missing() { + let dir = TempDir::new().unwrap(); + std::env::set_current_dir(dir.path()).unwrap(); + + let router = DeveloperRouter::new(); + let instructions = router.instructions(); + + assert!(!instructions.contains("Project Hints")); + } + + static DEV_ROUTER: OnceCell = OnceCell::const_new(); + + async fn get_router() -> &'static DeveloperRouter { + DEV_ROUTER + .get_or_init(|| async { DeveloperRouter::new() }) + .await + } + + #[tokio::test] + #[serial] + async fn test_shell_missing_parameters() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + let router = get_router().await; + let result = router.call_tool("shell", json!({})).await; + + assert!(result.is_err()); + let err = result.err().unwrap(); + assert!(matches!(err, ToolError::InvalidParameters(_))); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_text_editor_size_limits() { + // Create temp directory first so it stays in scope for the whole test + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Get router after setting current directory + let router = get_router().await; + + // Test file size limit + { + let large_file_path = temp_dir.path().join("large.txt"); + let large_file_str = large_file_path.to_str().unwrap(); + + // Create a file larger than 2MB + let content = "x".repeat(3 * 1024 * 1024); // 3MB + std::fs::write(&large_file_path, content).unwrap(); + + let result = router + .call_tool( + "text_editor", + json!({ + "command": "view", + "path": large_file_str + }), + ) + .await; + + assert!(result.is_err()); + let err = result.err().unwrap(); + assert!(matches!(err, ToolError::ExecutionError(_))); + assert!(err.to_string().contains("too large")); + } + + // Test character count limit + { + let many_chars_path = temp_dir.path().join("many_chars.txt"); + let many_chars_str = many_chars_path.to_str().unwrap(); + + // Create a file with more than 2^20 characters but less than 2MB + let content = "x".repeat((1 << 20) + 1); // 2^20 + 1 characters + std::fs::write(&many_chars_path, content).unwrap(); + + let result = router + .call_tool( + "text_editor", + json!({ + "command": "view", + "path": many_chars_str + }), + ) + .await; + + assert!(result.is_err()); + let err = result.err().unwrap(); + assert!(matches!(err, ToolError::ExecutionError(_))); + assert!(err.to_string().contains("too many characters")); + } + + // Let temp_dir drop naturally at end of scope + } + + #[tokio::test] + #[serial] + async fn test_text_editor_write_and_view_file() { + let router = get_router().await; + + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + let file_path_str = file_path.to_str().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a new file + router + .call_tool( + "text_editor", + json!({ + "command": "write", + "path": file_path_str, + "file_text": "Hello, world!" + }), + ) + .await + .unwrap(); + + // View the file + let view_result = router + .call_tool( + "text_editor", + json!({ + "command": "view", + "path": file_path_str + }), + ) + .await + .unwrap(); + + assert!(!view_result.is_empty()); + let text = view_result + .iter() + .find(|c| { + c.audience() + .is_some_and(|roles| roles.contains(&Role::User)) + }) + .unwrap() + .as_text() + .unwrap(); + assert!(text.contains("Hello, world!")); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_text_editor_str_replace() { + let router = get_router().await; + + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + let file_path_str = file_path.to_str().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a new file + router + .call_tool( + "text_editor", + json!({ + "command": "write", + "path": file_path_str, + "file_text": "Hello, world!" + }), + ) + .await + .unwrap(); + + // Replace string + let replace_result = router + .call_tool( + "text_editor", + json!({ + "command": "str_replace", + "path": file_path_str, + "old_str": "world", + "new_str": "Rust" + }), + ) + .await + .unwrap(); + + let text = replace_result + .iter() + .find(|c| { + c.audience() + .is_some_and(|roles| roles.contains(&Role::Assistant)) + }) + .unwrap() + .as_text() + .unwrap(); + + assert!(text.contains("has been edited, and the section now reads")); + + // View the file to verify the change + let view_result = router + .call_tool( + "text_editor", + json!({ + "command": "view", + "path": file_path_str + }), + ) + .await + .unwrap(); + + let text = view_result + .iter() + .find(|c| { + c.audience() + .is_some_and(|roles| roles.contains(&Role::User)) + }) + .unwrap() + .as_text() + .unwrap(); + assert!(text.contains("Hello, Rust!")); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_text_editor_undo_edit() { + let router = get_router().await; + + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + let file_path_str = file_path.to_str().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a new file + router + .call_tool( + "text_editor", + json!({ + "command": "write", + "path": file_path_str, + "file_text": "First line" + }), + ) + .await + .unwrap(); + + // Replace string + router + .call_tool( + "text_editor", + json!({ + "command": "str_replace", + "path": file_path_str, + "old_str": "First line", + "new_str": "Second line" + }), + ) + .await + .unwrap(); + + // Undo the edit + let undo_result = router + .call_tool( + "text_editor", + json!({ + "command": "undo_edit", + "path": file_path_str + }), + ) + .await + .unwrap(); + + let text = undo_result.first().unwrap().as_text().unwrap(); + assert!(text.contains("Undid the last edit")); + + // View the file to verify the undo + let view_result = router + .call_tool( + "text_editor", + json!({ + "command": "view", + "path": file_path_str + }), + ) + .await + .unwrap(); + + let text = view_result + .iter() + .find(|c| { + c.audience() + .is_some_and(|roles| roles.contains(&Role::User)) + }) + .unwrap() + .as_text() + .unwrap(); + assert!(text.contains("First line")); + + temp_dir.close().unwrap(); + } +} diff --git a/crates/goose-mcp/src/google_drive/mod.rs b/crates/goose-mcp/src/google_drive/mod.rs new file mode 100644 index 00000000..d8dc2f96 --- /dev/null +++ b/crates/goose-mcp/src/google_drive/mod.rs @@ -0,0 +1,585 @@ +use indoc::indoc; +use regex::Regex; +use serde_json::{json, Value}; + +use std::{env, fs, future::Future, io::Write, path::Path, pin::Pin}; + +use mcp_core::{ + handler::{ResourceError, ToolError}, + protocol::ServerCapabilities, + resource::Resource, + tool::Tool, +}; +use mcp_server::router::CapabilitiesBuilder; +use mcp_server::Router; + +use mcp_core::content::Content; + +use google_drive3::{ + self, + api::{File, Scope}, + hyper_rustls::{self, HttpsConnector}, + hyper_util::{self, client::legacy::connect::HttpConnector}, + yup_oauth2::{ + self, + authenticator_delegate::{DefaultInstalledFlowDelegate, InstalledFlowDelegate}, + InstalledFlowAuthenticator, + }, + DriveHub, +}; + +use http_body_util::BodyExt; + +/// async function to be pinned by the `present_user_url` method of the trait +/// we use the existing `DefaultInstalledFlowDelegate::present_user_url` method as a fallback for +/// when the browser did not open for example, the user still see's the URL. +async fn browser_user_url(url: &str, need_code: bool) -> Result { + tracing::info!(oauth_url = url, "Attempting OAuth login flow"); + if let Err(e) = webbrowser::open(url) { + tracing::debug!(oauth_url = url, error = ?e, "Failed to open OAuth flow"); + println!("Please open this URL in your browser:\n{}", url); + } + let def_delegate = DefaultInstalledFlowDelegate; + def_delegate.present_user_url(url, need_code).await +} + +/// our custom delegate struct we will implement a flow delegate trait for: +/// in this case we will implement the `InstalledFlowDelegated` trait +#[derive(Copy, Clone)] +struct LocalhostBrowserDelegate; + +/// here we implement only the present_user_url method with the added webbrowser opening +/// the other behaviour of the trait does not need to be changed. +impl InstalledFlowDelegate for LocalhostBrowserDelegate { + /// the actual presenting of URL and browser opening happens in the function defined above here + /// we only pin it + fn present_user_url<'a>( + &'a self, + url: &'a str, + need_code: bool, + ) -> Pin> + Send + 'a>> { + Box::pin(browser_user_url(url, need_code)) + } +} + +pub struct GoogleDriveRouter { + tools: Vec, + instructions: String, + drive: DriveHub>, +} + +impl GoogleDriveRouter { + async fn google_auth() -> DriveHub> { + let oauth_config = env::var("GOOGLE_DRIVE_OAUTH_CONFIG"); + let keyfile_path_str = env::var("GOOGLE_DRIVE_OAUTH_PATH") + .unwrap_or_else(|_| "./gcp-oauth.keys.json".to_string()); + let credentials_path_str = env::var("GOOGLE_DRIVE_CREDENTIALS_PATH") + .unwrap_or_else(|_| "./gdrive-server-credentials.json".to_string()); + + let expanded_keyfile = shellexpand::tilde(keyfile_path_str.as_str()); + let keyfile_path = Path::new(expanded_keyfile.as_ref()); + + let expanded_credentials = shellexpand::tilde(credentials_path_str.as_str()); + let credentials_path = Path::new(expanded_credentials.as_ref()); + + tracing::info!( + credentials_path = credentials_path_str, + keyfile_path = keyfile_path_str, + "Google Drive MCP server authentication config paths" + ); + + if !keyfile_path.exists() && oauth_config.is_ok() { + tracing::debug!( + oauth_config = ?oauth_config, + "Google Drive MCP server OAuth config" + ); + // attempt to create the path + if let Some(parent_dir) = keyfile_path.parent() { + let _ = fs::create_dir_all(parent_dir); + } + + if let Ok(mut file) = fs::File::create(keyfile_path) { + let _ = file.write_all(oauth_config.unwrap().as_bytes()); + } + } + + let secret = yup_oauth2::read_application_secret(keyfile_path) + .await + .expect("expected keyfile for google auth"); + + let auth = InstalledFlowAuthenticator::builder( + secret, + yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect, + ) + .persist_tokens_to_disk(credentials_path) + .flow_delegate(Box::new(LocalhostBrowserDelegate)) + .build() + .await + .expect("expected successful authentication"); + + let client = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build( + hyper_rustls::HttpsConnectorBuilder::new() + .with_native_roots() + .unwrap() + .https_or_http() + .enable_http1() + .build(), + ); + + DriveHub::new(client, auth) + } + + pub async fn new() -> Self { + let drive = Self::google_auth().await; + + // handle auth + let search_tool = Tool::new( + "search".to_string(), + indoc! {r#" + Search for files in google drive by name, given an input search query. + "#} + .to_string(), + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query", + }, + }, + "required": ["query"], + }), + ); + + let read_tool = Tool::new( + "read".to_string(), + indoc! {r#" + Read a file from google drive using the file uri. + Optionally include base64 encoded images, false by default. + "#} + .to_string(), + json!({ + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "google drive uri of the file to read", + }, + "includeImages": { + "type": "boolean", + "description": "Whether or not to include images as base64 encoded strings, defaults to false", + } + }, + "required": ["uri"], + }), + ); + + let instructions = indoc::formatdoc! {r#" + Google Drive MCP Server Instructions + + ## Overview + The Google Drive MCP server provides two main tools for interacting with Google Drive files: + 1. search - Find files in your Google Drive + 2. read - Read file contents directly using a uri in the `gdrive:///uri` format + + ## Available Tools + + ### 1. Search Tool + Search for files in Google Drive, by name and ordered by most recently viewedByMeTime. + Returns: List of files with their names, MIME types, and IDs + + ### 2. Read File Tool + Read a file's contents using its ID, and optionally include images as base64 encoded data. + The default is to exclude images, to include images set includeImages to true in the query. + + Images take up a large amount of context, this should only be used if a + user explicity needs the image data. + + Limitations: Google Sheets exporting only supports reading the first sheet. This is an important limitation that should + be communicated to the user whenever dealing with a Google Sheet (mimeType: application/vnd.google-apps.spreadsheet). + + ## File Format Handling + The server automatically handles different file types: + - Google Docs β†’ Markdown + - Google Sheets β†’ CSV + - Google Presentations β†’ Plain text + - Text/JSON files β†’ UTF-8 text + - Binary files β†’ Base64 encoded + + ## Common Usage Pattern + + 1. First, search for the file you want to read, searching by name. + 2. Then, use the file URI from the search results to read its contents. + + ## Best Practices + 1. Always use search first to find the correct file URI + 2. Search results include file types (MIME types) to help identify the right file + 3. Search is limited to 10 results per query, so use specific search terms + 4. The server has read-only access to Google Drive + + ## Error Handling + If you encounter errors: + 1. Verify the file URI is correct + 2. Ensure you have access to the file + 3. Check if the file format is supported + 4. Verify the server is properly configured + + Remember: Always use the tools in sequence - search first to get the file URI, then read to access the contents. + "#}; + + Self { + tools: vec![search_tool, read_tool], + instructions, + drive, + } + } + + // Implement search tool functionality + async fn search(&self, params: Value) -> Result, ToolError> { + let query = params + .get("query") + .and_then(|q| q.as_str()) + .ok_or(ToolError::InvalidParameters( + "The query string is required".to_string(), + ))? + .replace('\\', "\\\\") + .replace('\'', "\\'"); + + let result = self + .drive + .files() + .list() + .q(format!("name contains '{}'", query).as_str()) + .order_by("viewedByMeTime desc") + .param("fields", "files(id, name, mimeType, modifiedTime, size)") + .page_size(10) + .supports_all_drives(true) + .include_items_from_all_drives(true) + .clear_scopes() // Scope::MeetReadonly is the default, remove it + .add_scope(Scope::Readonly) + .doit() + .await; + + match result { + Err(e) => Err(ToolError::ExecutionError(format!( + "Failed to execute google drive search query, {}.", + e + ))), + Ok(r) => { + let content = + r.1.files + .map(|files| { + files.into_iter().map(|f| { + format!( + "{} ({}) (uri: {})", + f.name.unwrap_or_default(), + f.mime_type.unwrap_or_default(), + f.id.unwrap_or_default() + ) + }) + }) + .into_iter() + .flatten() + .collect::>() + .join("\n"); + + Ok(vec![Content::text(content.to_string())]) + } + } + } + + async fn fetch_file_metadata(&self, uri: &str) -> Result { + self.drive + .files() + .get(uri) + .param("fields", "mimeType") + .supports_all_drives(true) + .clear_scopes() + .add_scope(Scope::Readonly) + .doit() + .await + .map_err(|e| { + ToolError::ExecutionError(format!( + "Failed to execute Google Drive get query, {}.", + e + )) + }) + .map(|r| r.1) + } + + fn strip_image_body(&self, input: &str) -> String { + let image_regex = Regex::new(r"]+>").unwrap(); + image_regex.replace_all(input, "").to_string() + } + + // Downloading content with alt=media only works if the file is stored in Drive. + // To download Google Docs, Sheets, and Slides use files.export instead. + async fn export_google_file( + &self, + uri: &str, + mime_type: &str, + include_images: bool, + ) -> Result, ToolError> { + let export_mime_type = match mime_type { + "application/vnd.google-apps.document" => "text/markdown", + "application/vnd.google-apps.spreadsheet" => "text/csv", + "application/vnd.google-apps.presentation" => "text/plain", + _ => "text/plain", + }; + + let result = self + .drive + .files() + .export(uri, export_mime_type) + .param("alt", "media") + .clear_scopes() + .add_scope(Scope::Readonly) + .doit() + .await; + + match result { + Err(e) => Err(ToolError::ExecutionError(format!( + "Failed to execute google drive export for {}, {}.", + uri, e + ))), + Ok(r) => { + if let Ok(body) = r.into_body().collect().await { + if let Ok(response) = String::from_utf8(body.to_bytes().to_vec()) { + let content = if !include_images { + self.strip_image_body(&response) + } else { + response + }; + + Ok(vec![Content::text(content).with_priority(0.1)]) + } else { + Err(ToolError::ExecutionError(format!( + "Failed to export google drive to string, {}.", + uri, + ))) + } + } else { + Err(ToolError::ExecutionError(format!( + "Failed to export google drive document, {}.", + uri, + ))) + } + } + } + } + + // handle for files we can use files.get on + async fn get_google_file( + &self, + uri: &str, + include_images: bool, + ) -> Result, ToolError> { + let result = self + .drive + .files() + .get(uri) + .param("alt", "media") + .clear_scopes() + .add_scope(Scope::Readonly) + .doit() + .await; + + match result { + Err(e) => Err(ToolError::ExecutionError(format!( + "Failed to execute google drive export for {}, {}.", + uri, e + ))), + Ok(r) => { + let file = r.1; + let mime_type = file + .mime_type + .unwrap_or("application/octet-stream".to_string()); + if mime_type.starts_with("text/") || mime_type == "application/json" { + if let Ok(body) = r.0.into_body().collect().await { + if let Ok(response) = String::from_utf8(body.to_bytes().to_vec()) { + let content = if !include_images { + self.strip_image_body(&response) + } else { + response + }; + + Ok(vec![Content::text(content).with_priority(0.1)]) + } else { + Err(ToolError::ExecutionError(format!( + "Failed to convert google drive to string, {}.", + uri, + ))) + } + } else { + Err(ToolError::ExecutionError(format!( + "Failed to get google drive document, {}.", + uri, + ))) + } + } else { + //TODO: handle base64 image case, see typscript mcp-gdrive + Err(ToolError::ExecutionError(format!( + "Suported mimeType {}, for {}", + mime_type, uri, + ))) + } + } + } + } + + async fn read(&self, params: Value) -> Result, ToolError> { + let uri = + params + .get("uri") + .and_then(|q| q.as_str()) + .ok_or(ToolError::InvalidParameters( + "The uri of the file is required".to_string(), + ))?; + + let drive_uri = uri.replace("gdrive:///", ""); + + let include_images = params + .get("includeImages") + .and_then(|i| i.as_bool()) + .unwrap_or(false); + + let metadata = self.fetch_file_metadata(&drive_uri).await?; + let mime_type = metadata.mime_type.ok_or_else(|| { + ToolError::ExecutionError(format!("Missing mime type in file metadata for {}.", uri)) + })?; + + // Handle Google Docs export + if mime_type.starts_with("application/vnd.google-apps") { + self.export_google_file(&drive_uri, &mime_type, include_images) + .await + } else { + self.get_google_file(&drive_uri, include_images).await + } + } + + async fn read_google_resource(&self, uri: String) -> Result { + self.read(json!({"uri": uri})) + .await + .map_err(|e| ResourceError::ExecutionError(e.to_string())) + .map(|contents| { + contents + .into_iter() + .map(|content| content.as_text().unwrap_or_default().to_string()) + .collect::>() + .join("\n") + }) + } + + async fn list_google_resources(&self, params: Value) -> Vec { + let next_page_token = params.get("cursor").and_then(|q| q.as_str()); + + let mut query = self + .drive + .files() + .list() + .order_by("viewedByMeTime desc") + .page_size(10) + .param("fields", "nextPageToken, files(id, name, mimeType)") + .supports_all_drives(true) + .include_items_from_all_drives(true) + .clear_scopes() // Scope::MeetReadonly is the default, remove it + .add_scope(Scope::Readonly); + + // add a next token if we have one + if let Some(token) = next_page_token { + query = query.page_token(token) + } + + let result = query.doit().await; + + match result { + Err(_) => { + //Err(ResourceError::ExecutionError(format!( + // "Failed to execute google drive list query, {}.", + // e, + //))); + vec![] + } + Ok(r) => { + r.1.files + .map(|files| { + files.into_iter().map(|f| Resource { + uri: f.id.unwrap_or_default(), + mime_type: f.mime_type.unwrap_or_default(), + name: f.name.unwrap_or_default(), + description: None, + annotations: None, + }) + }) + .into_iter() + .flatten() + .collect::>() + } + } + } +} + +impl Router for GoogleDriveRouter { + fn name(&self) -> String { + "google_drive".to_string() + } + + fn instructions(&self) -> String { + self.instructions.clone() + } + + fn capabilities(&self) -> ServerCapabilities { + CapabilitiesBuilder::new() + .with_tools(false) + .with_resources(false, false) + .build() + } + + fn list_tools(&self) -> Vec { + self.tools.clone() + } + + fn call_tool( + &self, + tool_name: &str, + arguments: Value, + ) -> Pin, ToolError>> + Send + 'static>> { + let this = self.clone(); + let tool_name = tool_name.to_string(); + Box::pin(async move { + match tool_name.as_str() { + "search" => this.search(arguments).await, + "read" => this.read(arguments).await, + _ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))), + } + }) + } + + fn list_resources(&self) -> Vec { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { self.list_google_resources(json!({})).await }) + }) + } + + fn read_resource( + &self, + uri: &str, + ) -> Pin> + Send + 'static>> { + let this = self.clone(); + let uri_clone = uri.to_string(); + Box::pin(async move { this.read_google_resource(uri_clone).await }) + } +} + +impl Clone for GoogleDriveRouter { + fn clone(&self) -> Self { + Self { + tools: self.tools.clone(), + instructions: self.instructions.clone(), + drive: self.drive.clone(), + } + } +} diff --git a/crates/goose-mcp/src/jetbrains/mod.rs b/crates/goose-mcp/src/jetbrains/mod.rs new file mode 100644 index 00000000..319cdcd3 --- /dev/null +++ b/crates/goose-mcp/src/jetbrains/mod.rs @@ -0,0 +1,217 @@ +mod proxy; + +use anyhow::Result; +use mcp_core::{ + content::Content, + handler::{ResourceError, ToolError}, + protocol::ServerCapabilities, + resource::Resource, + role::Role, + tool::Tool, +}; +use mcp_server::router::CapabilitiesBuilder; +use mcp_server::Router; +use serde_json::Value; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::time::{sleep, Duration}; +use tracing::error; + +use self::proxy::JetBrainsProxy; + +pub struct JetBrainsRouter { + tools: Arc>>, + proxy: Arc, + instructions: String, +} + +impl Default for JetBrainsRouter { + fn default() -> Self { + Self::new() + } +} + +impl JetBrainsRouter { + pub fn new() -> Self { + let tools = Arc::new(Mutex::new(Vec::new())); + let proxy = Arc::new(JetBrainsProxy::new()); + let instructions = "JetBrains IDE integration".to_string(); + + // Initialize the proxy + let proxy_clone = Arc::clone(&proxy); + tokio::spawn(async move { + if let Err(e) = proxy_clone.start().await { + error!("Failed to start JetBrains proxy: {}", e); + } + }); + + // Start the background task to update tools + let tools_clone = Arc::clone(&tools); + let proxy_clone = Arc::clone(&proxy); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(5)); + loop { + interval.tick().await; + match proxy_clone.list_tools().await { + Ok(new_tools) => { + let mut tools = tools_clone.lock().await; + *tools = new_tools; + } + Err(e) => { + error!("Failed to update tools: {}", e); + } + } + } + }); + + Self { + tools, + proxy, + instructions, + } + } + + async fn call_proxy_tool( + &self, + tool_name: String, + arguments: Value, + ) -> Result, ToolError> { + let result = self + .proxy + .call_tool(&tool_name, arguments) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + // Create a success message for the assistant + let mut contents = vec![ + Content::text(format!("Tool {} executed successfully", tool_name)) + .with_audience(vec![Role::Assistant]), + ]; + + // Add the tool's result contents + contents.extend(result.content); + + Ok(contents) + } + + async fn ensure_tools(&self) -> Result<(), ToolError> { + let mut retry_count = 0; + let max_retries = 50; // 5 second total wait time + let retry_delay = Duration::from_millis(100); + + while retry_count < max_retries { + let tools = self.tools.lock().await; + if !tools.is_empty() { + return Ok(()); + } + drop(tools); // Release the lock before sleeping + + sleep(retry_delay).await; + retry_count += 1; + } + + Err(ToolError::ExecutionError("Failed to get tools list from IDE. Make sure the IDE is running and the plugin is installed.".to_string())) + } +} + +impl Router for JetBrainsRouter { + fn name(&self) -> String { + "jetbrains".to_string() + } + + fn instructions(&self) -> String { + self.instructions.clone() + } + + fn capabilities(&self) -> ServerCapabilities { + CapabilitiesBuilder::new().with_tools(true).build() + } + + fn list_tools(&self) -> Vec { + // Use block_in_place to avoid blocking the runtime + tokio::task::block_in_place(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let tools = self.tools.lock().await; + if tools.is_empty() { + drop(tools); + if let Err(e) = self.ensure_tools().await { + error!("Failed to ensure tools: {}", e); + vec![] + } else { + self.tools.lock().await.clone() + } + } else { + tools.clone() + } + }) + }) + } + + fn call_tool( + &self, + tool_name: &str, + arguments: Value, + ) -> Pin, ToolError>> + Send + 'static>> { + let this = self.clone(); + let tool_name = tool_name.to_string(); + Box::pin(async move { + this.ensure_tools().await?; + this.call_proxy_tool(tool_name, arguments).await + }) + } + + fn list_resources(&self) -> Vec { + vec![] + } + + fn read_resource( + &self, + _uri: &str, + ) -> Pin> + Send + 'static>> { + Box::pin(async { Err(ResourceError::NotFound("Resource not found".into())) }) + } +} + +impl Clone for JetBrainsRouter { + fn clone(&self) -> Self { + Self { + tools: Arc::clone(&self.tools), + proxy: Arc::clone(&self.proxy), + instructions: self.instructions.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::OnceCell; + + static JETBRAINS_ROUTER: OnceCell = OnceCell::const_new(); + + async fn get_router() -> &'static JetBrainsRouter { + JETBRAINS_ROUTER + .get_or_init(|| async { JetBrainsRouter::new() }) + .await + } + + #[tokio::test] + async fn test_router_creation() { + let router = get_router().await; + assert_eq!(router.name(), "jetbrains"); + assert!(!router.instructions().is_empty()); + } + + #[tokio::test] + async fn test_capabilities() { + let router = get_router().await; + let capabilities = router.capabilities(); + assert!(capabilities.tools.is_some()); + } +} diff --git a/crates/goose-mcp/src/jetbrains/proxy.rs b/crates/goose-mcp/src/jetbrains/proxy.rs new file mode 100644 index 00000000..8c0ec34a --- /dev/null +++ b/crates/goose-mcp/src/jetbrains/proxy.rs @@ -0,0 +1,341 @@ +use anyhow::{anyhow, Result}; +use mcp_core::{Content, Tool}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::env; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; +use tracing::{debug, error, info}; + +const PORT_RANGE_START: u16 = 63342; +const PORT_RANGE_END: u16 = 63352; +const ENDPOINT_CHECK_INTERVAL: Duration = Duration::from_secs(10); + +#[derive(Debug, Serialize, Deserialize)] +struct IDEResponseOk { + status: String, + error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct IDEResponseErr { + status: Option, + error: String, +} + +#[derive(Debug, Serialize)] +pub struct CallToolResult { + pub content: Vec, + pub is_error: bool, +} + +#[derive(Debug)] +pub struct JetBrainsProxy { + cached_endpoint: Arc>>, + previous_response: Arc>>, + client: Client, +} + +impl JetBrainsProxy { + pub fn new() -> Self { + Self { + cached_endpoint: Arc::new(RwLock::new(None)), + previous_response: Arc::new(RwLock::new(None)), + client: Client::new(), + } + } + + async fn test_list_tools(&self, endpoint: &str) -> Result { + debug!("Sending test request to {}/mcp/list_tools", endpoint); + + let response = match self + .client + .get(format!("{}/mcp/list_tools", endpoint)) + .send() + .await + { + Ok(resp) => { + debug!("Got response with status: {}", resp.status()); + resp + } + Err(e) => { + debug!("Error testing endpoint {}: {}", endpoint, e); + return Ok(false); + } + }; + + if !response.status().is_success() { + debug!("Test request failed with status {}", response.status()); + return Ok(false); + } + + let current_response = response.text().await?; + debug!("Received response: {}", current_response); + + // Try to parse as JSON array to validate format + if serde_json::from_str::>(¤t_response).is_err() { + debug!("Response is not a valid JSON array of tools"); + return Ok(false); + } + + let mut prev_response = self.previous_response.write().await; + if let Some(prev) = prev_response.as_ref() { + if prev != ¤t_response { + debug!("Response changed since last check"); + self.send_tools_changed().await; + } + } + *prev_response = Some(current_response); + + Ok(true) + } + + async fn find_working_ide_endpoint(&self) -> Result { + debug!("Attempting to find working IDE endpoint..."); + + // Check IDE_PORT environment variable first + if let Ok(port) = env::var("IDE_PORT") { + debug!("Found IDE_PORT environment variable: {}", port); + let test_endpoint = format!("http://127.0.0.1:{}/api", port); + if self.test_list_tools(&test_endpoint).await? { + debug!("IDE_PORT {} is working", port); + return Ok(test_endpoint); + } + debug!("IDE_PORT {} is not responding correctly", port); + return Err(anyhow!( + "Specified IDE_PORT={} is not responding correctly", + port + )); + } + + debug!( + "No IDE_PORT environment variable, scanning port range {}-{}", + PORT_RANGE_START, PORT_RANGE_END + ); + + // Scan port range + for port in PORT_RANGE_START..=PORT_RANGE_END { + let candidate_endpoint = format!("http://127.0.0.1:{}/api", port); + debug!("Testing port {}...", port); + + if self.test_list_tools(&candidate_endpoint).await? { + debug!("Found working IDE endpoint at {}", candidate_endpoint); + return Ok(candidate_endpoint); + } + } + + debug!("No working IDE endpoint found in port range"); + Err(anyhow!( + "No working IDE endpoint found in range {}-{}", + PORT_RANGE_START, + PORT_RANGE_END + )) + } + + async fn update_ide_endpoint(&self) { + debug!("Updating IDE endpoint..."); + match self.find_working_ide_endpoint().await { + Ok(endpoint) => { + let mut cached = self.cached_endpoint.write().await; + *cached = Some(endpoint.clone()); + debug!("Updated cached endpoint to: {}", endpoint); + } + Err(e) => { + debug!("Failed to update IDE endpoint: {}", e); + error!("Failed to update IDE endpoint: {}", e); + } + } + } + + pub async fn list_tools(&self) -> Result> { + debug!("Listing tools..."); + let endpoint = { + let cached = self.cached_endpoint.read().await; + match cached.as_ref() { + Some(ep) => { + debug!("Using cached endpoint: {}", ep); + ep.clone() + } + None => { + debug!("No cached endpoint available"); + return Ok(vec![]); + } + } + }; + + debug!("Sending list_tools request to {}/mcp/list_tools", endpoint); + let response = match self + .client + .get(format!("{}/mcp/list_tools", endpoint)) + .send() + .await + { + Ok(resp) => { + debug!("Got response with status: {}", resp.status()); + resp + } + Err(e) => { + debug!("Failed to send request: {}", e); + return Err(anyhow!("Failed to send request: {}", e)); + } + }; + + if !response.status().is_success() { + debug!("Request failed with status: {}", response.status()); + return Err(anyhow!( + "Failed to fetch tools with status {}", + response.status() + )); + } + + let response_text = response.text().await?; + debug!("Got response text: {}", response_text); + + let tools_response: Value = serde_json::from_str(&response_text).map_err(|e| { + debug!("Failed to parse response as JSON: {}", e); + anyhow!("Failed to parse response as JSON: {}", e) + })?; + + debug!("Parsed JSON response: {:?}", tools_response); + + let tools: Vec = tools_response + .as_array() + .ok_or_else(|| { + debug!("Response is not a JSON array"); + anyhow!("Invalid tools response format: not an array") + })? + .iter() + .filter_map(|t| { + if let (Some(name), Some(description)) = + (t["name"].as_str(), t["description"].as_str()) + { + // Get just the first sentence of the description + let first_sentence = description + .split('.') + .next() + .unwrap_or(description) + .trim() + .to_string() + + "."; + + // Handle input_schema as either a string or an object + let input_schema = match &t["inputSchema"] { + Value::String(s) => Value::String(s.clone()), + Value::Object(o) => Value::Object(o.clone()), + _ => { + debug!( + "Invalid inputSchema format for tool {}: {:?}", + name, t["inputSchema"] + ); + return None; + } + }; + + Some(Tool { + name: name.to_string(), + description: first_sentence, + input_schema, + }) + } else { + debug!("Skipping invalid tool entry: {:?}", t); + None + } + }) + .collect(); + + debug!("Collected {} tools", tools.len()); + Ok(tools) + } + + pub async fn call_tool(&self, name: &str, args: Value) -> Result { + let endpoint = self + .cached_endpoint + .read() + .await + .clone() + .ok_or_else(|| anyhow!("No working IDE endpoint available"))?; + + debug!( + "ENDPOINT: {} | Tool name: {} | args: {}", + endpoint, name, args + ); + + let response = self + .client + .post(format!("{}/mcp/{}", endpoint, name)) + .json(&args) + .send() + .await?; + + if !response.status().is_success() { + debug!("Response failed with status: {}", response.status()); + return Err(anyhow!("Response failed: {}", response.status())); + } + + let ide_response: Value = response.json().await?; + let (is_error, text) = match ide_response { + Value::Object(map) => { + let status = map.get("status").and_then(|v| v.as_str()); + let error = map.get("error").and_then(|v| v.as_str()); + + match (status, error) { + (Some(s), None) => (false, s.to_string()), + (None, Some(e)) => (true, e.to_string()), + _ => { + debug!("Invalid response format from IDE"); + return Err(anyhow!("Invalid response format from IDE")); + } + } + } + _ => { + debug!("Unexpected response type from IDE"); + return Err(anyhow!("Unexpected response type from IDE")); + } + }; + + Ok(CallToolResult { + content: vec![Content::text(text)], + is_error, + }) + } + + async fn send_tools_changed(&self) { + debug!("Sending tools changed notification"); + // TODO: Implement notification mechanism when needed + } + + pub async fn start(&self) -> Result<()> { + debug!("Initializing JetBrains Proxy..."); + info!("Initializing JetBrains Proxy..."); + + // Initial endpoint check + debug!("Performing initial endpoint check..."); + self.update_ide_endpoint().await; + + // Schedule periodic endpoint checks + let proxy = self.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(ENDPOINT_CHECK_INTERVAL).await; + debug!("Performing periodic endpoint check..."); + proxy.update_ide_endpoint().await; + } + }); + + debug!("JetBrains Proxy running"); + info!("JetBrains Proxy running"); + Ok(()) + } +} + +impl Clone for JetBrainsProxy { + fn clone(&self) -> Self { + Self { + cached_endpoint: Arc::clone(&self.cached_endpoint), + previous_response: Arc::clone(&self.previous_response), + client: Client::new(), + } + } +} diff --git a/crates/goose-mcp/src/lib.rs b/crates/goose-mcp/src/lib.rs new file mode 100644 index 00000000..162fe6ce --- /dev/null +++ b/crates/goose-mcp/src/lib.rs @@ -0,0 +1,11 @@ +mod developer; +mod google_drive; +mod jetbrains; +mod memory; +mod nondeveloper; + +pub use developer::DeveloperRouter; +pub use google_drive::GoogleDriveRouter; +pub use jetbrains::JetBrainsRouter; +pub use memory::MemoryRouter; +pub use nondeveloper::NonDeveloperRouter; diff --git a/crates/goose-mcp/src/memory/mod.rs b/crates/goose-mcp/src/memory/mod.rs new file mode 100644 index 00000000..1de33605 --- /dev/null +++ b/crates/goose-mcp/src/memory/mod.rs @@ -0,0 +1,535 @@ +use async_trait::async_trait; +use indoc::formatdoc; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + fs, + future::Future, + io::{self, Read, Write}, + path::PathBuf, + pin::Pin, +}; + +use mcp_core::{ + handler::{ResourceError, ToolError}, + protocol::ServerCapabilities, + resource::Resource, + tool::{Tool, ToolCall}, + Content, +}; +use mcp_server::router::CapabilitiesBuilder; +use mcp_server::Router; + +// MemoryRouter implementation +#[derive(Clone)] +pub struct MemoryRouter { + tools: Vec, + instructions: String, + global_memory_dir: PathBuf, + local_memory_dir: PathBuf, +} + +impl Default for MemoryRouter { + fn default() -> Self { + Self::new() + } +} + +impl MemoryRouter { + pub fn new() -> Self { + let remember_memory = Tool::new( + "remember_memory", + "Stores a memory with optional tags in a specified category", + json!({ + "type": "object", + "properties": { + "category": {"type": "string"}, + "data": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "is_global": {"type": "boolean"} + }, + "required": ["category", "data", "is_global"] + }), + ); + + let retrieve_memories = Tool::new( + "retrieve_memories", + "Retrieves all memories from a specified category", + json!({ + "type": "object", + "properties": { + "category": {"type": "string"}, + "is_global": {"type": "boolean"} + }, + "required": ["category", "is_global"] + }), + ); + + let remove_memory_category = Tool::new( + "remove_memory_category", + "Removes all memories within a specified category", + json!({ + "type": "object", + "properties": { + "category": {"type": "string"}, + "is_global": {"type": "boolean"} + }, + "required": ["category", "is_global"] + }), + ); + + let remove_specific_memory = Tool::new( + "remove_specific_memory", + "Removes a specific memory within a specified category", + json!({ + "type": "object", + "properties": { + "category": {"type": "string"}, + "memory_content": {"type": "string"}, + "is_global": {"type": "boolean"} + }, + "required": ["category", "memory_content", "is_global"] + }), + ); + + let instructions = formatdoc! {r#" + This extension allows storage and retrieval of categorized information with tagging support. It's designed to help + manage important information across sessions in a systematic and organized manner. + Capabilities: + 1. Store information in categories with optional tags for context-based retrieval. + 2. Search memories by content or specific tags to find relevant information. + 3. List all available memory categories for easy navigation. + 4. Remove entire categories of memories when they are no longer needed. + Interaction Protocol: + When important information is identified, such as: + - User-specific data (e.g., name, preferences) + - Project-related configurations + - Workflow descriptions + - Other critical settings + The protocol is: + 1. Identify the critical piece of information. + 2. Ask the user if they'd like to store it for later reference. + 3. Upon agreement: + - Suggest a relevant category like "personal" for user data or "development" for project preferences. + - Inquire about any specific tags they want to apply for easier lookup. + - Confirm the desired storage location: + - Local storage (.goose/memory) for project-specific details. + - Global storage (~/.config/goose/memory) for user-wide data. + Example Interaction for Storing Information: + User: "For this project, we use black for code formatting" + Assistant: "You've mentioned a development preference. Would you like to remember this for future conversations? + User: "Yes, please." + Assistant: "I'll store this in the 'development' category. Any specific tags to add? Suggestions: #formatting + #tools" + User: "Yes, use those tags." + Assistant: "Shall I store this locally for this project only, or globally for all projects?" + User: "Locally, please." + Assistant: *Stores the information under category="development", tags="formatting tools", scope="local"* + Retrieving Memories: + To access stored information, utilize the memory retrieval protocols: + - **Search by Category**: + - Provides all memories within the specified context. + - Use: `retrieve_memories(category="development", is_global=False)` + - Note: If you want to retrieve all local memories, use `retrieve_memories(category="*", is_global=False)` + - Note: If you want to retrieve all global memories, use `retrieve_memories(category="*", is_global=True)` + - **Filter by Tags**: + - Enables targeted retrieval based on specific tags. + - Use: Provide tag filters to refine search. + To remove a memory, use the following protocol: + - **Remove by Category**: + - Removes all memories within the specified category. + - Use: `remove_memory_category(category="development", is_global=False)` + - Note: If you want to remove all local memories, use `remove_memory_category(category="*", is_global=False)` + - Note: If you want to remove all global memories, use `remove_memory_category(category="*", is_global=True)` + The Protocol is: + 1. Confirm what kind of information the user seeks by category or keyword. + 2. Suggest categories or relevant tags based on the user's request. + 3. Use the retrieve function to access relevant memory entries. + 4. Present a summary of findings, offering detailed exploration upon request. + Example Interaction for Retrieving Information: + User: "What configuration do we use for code formatting?" + Assistant: "Let me check the 'development' category for any related memories. Searching using #formatting tag." + Assistant: *Executes retrieval: `retrieve_memories(category="development", is_global=False)`* + Assistant: "We have 'black' configured for code formatting, specific to this project. Would you like further + details?" + Memory Overview: + - Categories can include a wide range of topics, structured to keep information grouped logically. + - Tags enable quick filtering and identification of specific entries. + Operational Guidelines: + - Always confirm with the user before saving information. + - Propose suitable categories and tag suggestions. + - Discuss storage scope thoroughly to align with user needs. + - Acknowledge the user about what is stored and where, for transparency and ease of future retrieval. + "#}; + + // Check for .goose/memory in current directory + let local_memory_dir = std::env::var("GOOSE_WORKING_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::current_dir().unwrap()) + .join(".goose") + .join("memory"); + + // Check for .config/goose/memory in user's home directory + let global_memory_dir = dirs::home_dir() + .map(|home| home.join(".config/goose/memory")) + .unwrap_or_else(|| PathBuf::from(".config/goose/memory")); + + fs::create_dir_all(&global_memory_dir).unwrap(); + fs::create_dir_all(&local_memory_dir).unwrap(); + + let mut memory_router = Self { + tools: vec![ + remember_memory, + retrieve_memories, + remove_memory_category, + remove_specific_memory, + ], + instructions: instructions.clone(), + global_memory_dir, + local_memory_dir, + }; + + let retrieved_global_memories = memory_router.retrieve_all(true); + let retrieved_local_memories = memory_router.retrieve_all(false); + + let mut updated_instructions = instructions; + + let memories_follow_up_instructions = formatdoc! {r#" + **Here are the user's currently saved memories:** + Please keep this information in mind when answering future questions. + Do not bring up memories unless relevant. + Note: if the user has not saved any memories, this section will be empty. + Note: if the user removes a memory that was previously loaded into the system, please remove it from the system instructions. + "#}; + + updated_instructions.push_str("\n\n"); + updated_instructions.push_str(&memories_follow_up_instructions); + + if let Ok(global_memories) = retrieved_global_memories { + if !global_memories.is_empty() { + updated_instructions.push_str("\n\nGlobal Memories:\n"); + for (category, memories) in global_memories { + updated_instructions.push_str(&format!("\nCategory: {}\n", category)); + for memory in memories { + updated_instructions.push_str(&format!("- {}\n", memory)); + } + } + } + } + + if let Ok(local_memories) = retrieved_local_memories { + if !local_memories.is_empty() { + updated_instructions.push_str("\n\nLocal Memories:\n"); + for (category, memories) in local_memories { + updated_instructions.push_str(&format!("\nCategory: {}\n", category)); + for memory in memories { + updated_instructions.push_str(&format!("- {}\n", memory)); + } + } + } + } + + memory_router.set_instructions(updated_instructions); + + memory_router + } + + // Add a setter method for instructions + pub fn set_instructions(&mut self, new_instructions: String) { + self.instructions = new_instructions; + } + + pub fn get_instructions(&self) -> &str { + &self.instructions + } + + fn get_memory_file(&self, category: &str, is_global: bool) -> PathBuf { + // Defaults to local memory if no is_global flag is provided + let base_dir = if is_global { + &self.global_memory_dir + } else { + &self.local_memory_dir + }; + base_dir.join(format!("{}.txt", category)) + } + + pub fn retrieve_all(&self, is_global: bool) -> io::Result>> { + let base_dir = if is_global { + &self.global_memory_dir + } else { + &self.local_memory_dir + }; + let mut memories = HashMap::new(); + if base_dir.exists() { + for entry in fs::read_dir(base_dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + let category = entry.file_name().to_string_lossy().replace(".txt", ""); + let category_memories = self.retrieve(&category, is_global)?; + memories.insert( + category, + category_memories.into_iter().flat_map(|(_, v)| v).collect(), + ); + } + } + } + Ok(memories) + } + + pub fn remember( + &self, + _context: &str, + category: &str, + data: &str, + tags: &[&str], + is_global: bool, + ) -> io::Result<()> { + let memory_file_path = self.get_memory_file(category, is_global); + + let mut file = fs::OpenOptions::new() + .append(true) + .create(true) + .open(&memory_file_path)?; + if !tags.is_empty() { + writeln!(file, "# {}", tags.join(" "))?; + } + writeln!(file, "{}\n", data)?; + + Ok(()) + } + + pub fn retrieve( + &self, + category: &str, + is_global: bool, + ) -> io::Result>> { + let memory_file_path = self.get_memory_file(category, is_global); + if !memory_file_path.exists() { + return Ok(HashMap::new()); + } + + let mut file = fs::File::open(memory_file_path)?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + + let mut memories = HashMap::new(); + for entry in content.split("\n\n") { + let mut lines = entry.lines(); + if let Some(first_line) = lines.next() { + if let Some(stripped) = first_line.strip_prefix('#') { + let tags = stripped + .split_whitespace() + .map(String::from) + .collect::>(); + memories.insert(tags.join(" "), lines.map(String::from).collect()); + } else { + let entry_data: Vec = std::iter::once(first_line.to_string()) + .chain(lines.map(String::from)) + .collect(); + memories + .entry("untagged".to_string()) + .or_insert_with(Vec::new) + .extend(entry_data); + } + } + } + + Ok(memories) + } + + pub fn remove_specific_memory( + &self, + category: &str, + memory_content: &str, + is_global: bool, + ) -> io::Result<()> { + let memory_file_path = self.get_memory_file(category, is_global); + if !memory_file_path.exists() { + return Ok(()); + } + + let mut file = fs::File::open(&memory_file_path)?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + + let memories: Vec<&str> = content.split("\n\n").collect(); + let new_content: Vec = memories + .into_iter() + .filter(|entry| !entry.contains(memory_content)) + .map(|s| s.to_string()) + .collect(); + + fs::write(memory_file_path, new_content.join("\n\n"))?; + + Ok(()) + } + + pub fn clear_memory(&self, category: &str, is_global: bool) -> io::Result<()> { + let memory_file_path = self.get_memory_file(category, is_global); + if memory_file_path.exists() { + fs::remove_file(memory_file_path)?; + } + + Ok(()) + } + + pub fn clear_all_global_or_local_memories(&self, is_global: bool) -> io::Result<()> { + let base_dir = if is_global { + &self.global_memory_dir + } else { + &self.local_memory_dir + }; + fs::remove_dir_all(base_dir)?; + Ok(()) + } + + async fn execute_tool_call(&self, tool_call: ToolCall) -> Result { + match tool_call.name.as_str() { + "remember_memory" => { + let args = MemoryArgs::from_value(&tool_call.arguments)?; + let data = args.data.filter(|d| !d.is_empty()).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Data must exist when remembering a memory", + ) + })?; + self.remember("context", args.category, data, &args.tags, args.is_global)?; + Ok(format!("Stored memory in category: {}", args.category)) + } + "retrieve_memories" => { + let args = MemoryArgs::from_value(&tool_call.arguments)?; + let memories = if args.category == "*" { + self.retrieve_all(args.is_global)? + } else { + self.retrieve(args.category, args.is_global)? + }; + Ok(format!("Retrieved memories: {:?}", memories)) + } + "remove_memory_category" => { + let args = MemoryArgs::from_value(&tool_call.arguments)?; + if args.category == "*" { + self.clear_all_global_or_local_memories(args.is_global)?; + Ok(format!( + "Cleared all memory {} categories", + if args.is_global { "global" } else { "local" } + )) + } else { + self.clear_memory(args.category, args.is_global)?; + Ok(format!("Cleared memories in category: {}", args.category)) + } + } + "remove_specific_memory" => { + let args = MemoryArgs::from_value(&tool_call.arguments)?; + let memory_content = tool_call.arguments["memory_content"].as_str().unwrap(); + self.remove_specific_memory(args.category, memory_content, args.is_global)?; + Ok(format!( + "Removed specific memory from category: {}", + args.category + )) + } + _ => Err(io::Error::new(io::ErrorKind::InvalidInput, "Unknown tool")), + } + } +} + +#[async_trait] +impl Router for MemoryRouter { + fn name(&self) -> String { + "memory".to_string() + } + + fn instructions(&self) -> String { + self.instructions.clone() + } + + fn capabilities(&self) -> ServerCapabilities { + CapabilitiesBuilder::new().with_tools(false).build() + } + + fn list_tools(&self) -> Vec { + self.tools.clone() + } + + fn call_tool( + &self, + tool_name: &str, + arguments: Value, + ) -> Pin, ToolError>> + Send + 'static>> { + let this = self.clone(); + let tool_name = tool_name.to_string(); + + Box::pin(async move { + let tool_call = ToolCall { + name: tool_name, + arguments, + }; + match this.execute_tool_call(tool_call).await { + Ok(result) => Ok(vec![Content::text(result)]), + Err(err) => Err(ToolError::ExecutionError(err.to_string())), + } + }) + } + + fn list_resources(&self) -> Vec { + Vec::new() + } + + fn read_resource( + &self, + _uri: &str, + ) -> Pin> + Send + 'static>> { + Box::pin(async move { Ok("".to_string()) }) + } +} + +#[derive(Debug)] +struct MemoryArgs<'a> { + category: &'a str, + data: Option<&'a str>, + tags: Vec<&'a str>, + is_global: bool, +} + +impl<'a> MemoryArgs<'a> { + // Category is required, data is optional, tags are optional, is_global is optional + fn from_value(args: &'a Value) -> Result { + let category = args["category"].as_str().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "Category must be a string") + })?; + + if category.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Category must be a string", + )); + } + + let data = args.get("data").and_then(|d| d.as_str()); + + let tags = match &args["tags"] { + Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect(), + Value::String(s) => vec![s.as_str()], + _ => Vec::new(), + }; + + let is_global = match &args.get("is_global") { + // Default to false if no is_global flag is provided + Some(Value::Bool(b)) => *b, + Some(Value::String(s)) => s.to_lowercase() == "true", + None => false, + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "is_global must be a boolean or string 'true'/'false'", + )) + } + }; + + Ok(Self { + category, + data, + tags, + is_global, + }) + } +} diff --git a/crates/goose-mcp/src/nondeveloper/mod.rs b/crates/goose-mcp/src/nondeveloper/mod.rs new file mode 100644 index 00000000..c857b90d --- /dev/null +++ b/crates/goose-mcp/src/nondeveloper/mod.rs @@ -0,0 +1,757 @@ +use base64::Engine; +use indoc::{formatdoc, indoc}; +use reqwest::{Client, Url}; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, fs, future::Future, os::unix::fs::PermissionsExt, path::PathBuf, + pin::Pin, sync::Arc, sync::Mutex, +}; +use tokio::process::Command; + +use mcp_core::{ + handler::{ResourceError, ToolError}, + protocol::ServerCapabilities, + resource::Resource, + tool::Tool, + Content, +}; +use mcp_server::router::CapabilitiesBuilder; +use mcp_server::Router; + +/// An extension designed for non-developers to help them with common tasks like +/// web scraping, data processing, and automation. +#[derive(Clone)] +pub struct NonDeveloperRouter { + tools: Vec, + cache_dir: PathBuf, + active_resources: Arc>>, + http_client: Client, + instructions: String, +} + +impl Default for NonDeveloperRouter { + fn default() -> Self { + Self::new() + } +} + +impl NonDeveloperRouter { + pub fn new() -> Self { + // Create tools for the system + let web_search_tool = Tool::new( + "web_search", + indoc! {r#" + Search the web for a single word (proper noun ideally) using DuckDuckGo's API. Returns results in JSON format. + The results are cached locally for future reference. + Be sparing as there is a limited number of api calls allowed. + "#}, + json!({ + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "A single word to search for, a topic, propernoun, brand name that you may not know about" + } + } + }), + ); + + let web_scrape_tool = Tool::new( + "web_scrape", + indoc! {r#" + Fetch and save content from a web page. The content can be saved as: + - text (for HTML pages) + - json (for API responses) + - binary (for images and other files) + + The content is cached locally and can be accessed later using the cache_path + returned in the response. + "#}, + json!({ + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "The URL to fetch content from" + }, + "save_as": { + "type": "string", + "enum": ["text", "json", "binary"], + "default": "text", + "description": "How to interpret and save the content" + } + } + }), + ); + + let computer_control_tool = Tool::new( + "computer_control", + indoc! {r#" + Control the computer using AppleScript (macOS only). + Allows automation of applications and system features through AppleScript. + + Common uses: + - Control applications (Mail, Safari, iTunes, etc.) + - System settings and notifications + - UI automation + - File and folder operations + - Calendar and reminders + - Media management + - Combining with the screenshot tool to help user achieve tasks. + + It allows users to control applications and system features programmatically. + Here's an overview of what AppleScript can automate: + Application Control + Launch, quit, or manage applications. + Interact with app-specific features (e.g., sending an email in Mail, creating a document in Pages, or editing photos in Preview). + Perform tasks in third-party apps that support AppleScript, such as Adobe Photoshop, Microsoft Office, or Safari. + User Interface Automation + Simulate user interactions like clicking buttons, selecting menu items, or typing text. + Fill out forms or automate repetitive tasks in apps. + System Settings and Utilities + Change system preferences (e.g., volume, screen brightness, Wi-Fi settings). + Automate tasks like shutting down, restarting, or putting the system to sleep. + Monitor system events or logs. + Web Automation + Open specific URLs in AppleScript-enabled browsers. + Automate web interactions (e.g., filling forms, navigating pages). + Scrape information from websites. + Email and Messaging + Automate sending and organizing emails in the Mail app. + Extract email contents or attachments. + Send messages via Messages, Slack etc + Media Management + Organize and edit iTunes/Music libraries (e.g., create playlists, change metadata). + Manage photos in Photos (e.g., creating albums, importing/exporting images). + Automate tasks in video or music production tools like Final Cut Pro or GarageBand. + Data Processing + Interact with spreadsheets (e.g., Numbers or Excel). + + "#}, + json!({ + "type": "object", + "required": ["script"], + "properties": { + "script": { + "type": "string", + "description": "The AppleScript content to execute" + }, + "save_output": { + "type": "boolean", + "default": false, + "description": "Whether to save the script output to a file" + } + } + }), + ); + + let quick_script_tool = Tool::new( + "automation_script", + indoc! {r#" + Create and run small scripts for automation tasks. + Supports Shell and Ruby (on macOS). + + The script is saved to a temporary file and executed. + Consider using shell script (bash) for most simple tasks first. + Ruby is useful for text processing or when you need more sophisticated scripting capabilities. + Some examples of shell: + - create a sorted list of unique lines: sort file.txt | uniq + - extract 2nd column in csv: awk -F "," '{ print $2}' + - pattern matching: grep pattern file.txt + "#}, + json!({ + "type": "object", + "required": ["language", "script"], + "properties": { + "language": { + "type": "string", + "enum": ["shell", "ruby"], + "description": "The scripting language to use" + }, + "script": { + "type": "string", + "description": "The script content" + }, + "save_output": { + "type": "boolean", + "default": false, + "description": "Whether to save the script output to a file" + } + } + }), + ); + + let cache_tool = Tool::new( + "cache", + indoc! {r#" + Manage cached files and data: + - list: List all cached files + - view: View content of a cached file + - delete: Delete a cached file + - clear: Clear all cached files + "#}, + json!({ + "type": "object", + "required": ["command"], + "properties": { + "command": { + "type": "string", + "enum": ["list", "view", "delete", "clear"], + "description": "The command to perform" + }, + "path": { + "type": "string", + "description": "Path to the cached file for view/delete commands" + } + } + }), + ); + + // Create cache directory in user's home directory + let cache_dir = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("goose") + .join("non_developer"); + fs::create_dir_all(&cache_dir).unwrap_or_else(|_| { + println!( + "Warning: Failed to create cache directory at {:?}", + cache_dir + ) + }); + + let instructions = formatdoc! {r#" + You are a helpful assistant to a power user who is not a professional developer, but you may use devleopment tools to help assist them. + The user may not know how to break down tasks, so you will need to ensure that you do, and run things in batches as needed. + The NonDeveloperExtension helps you with common tasks like web scraping, + data processing, and automation and computer control without requiring programming expertise, + supplementing the Developer Extension. + + You can use scripting as needed to work with text files of data, such as csvs, json, or text files etc. + Using the developer extension is allowed for more sophisticated tasks or instructed to (js or py can be helpful for more complex tasks if tools are available). + + Accessing web sites, even apis, may be common (you can use bash scripting to do this) without troubling them too much (they won't know what limits are). + Try to do your best to find ways to complete a task without too many quesitons or offering options unless it is really unclear, find a way if you can. + You can also guide them steps if they can help out as you go along. + + There is already a screenshot tool available you can use if needed to see what is on screen. + + Here are some extra tools: + automation_script + - Create and run simple automation scripts + - Supports Shell (such as bash), AppleScript (on macos), Ruby (on macos) + - Scripts can save their output to files + - on macos, can use applescript to interact with the desktop, eg calendars, notes and more, anything apple script can do for apps that support it: + AppleScript is a powerful scripting language designed for automating tasks on macOS such as: Integration with Other Scripts + Execute shell scripts, Ruby scripts, or other automation scripts. + Combine workflows across scripting languages. + Complex Workflows + Automate multi-step tasks involving multiple apps or system features. + Create scheduled tasks using Calendar or other scheduling apps. + + - use the screenshot tool if needed to help with tasks + + computer_control + - Control the computer using AppleScript (macOS only) + - Consider the screenshot tool to work out what is on screen and what to do to help with the control task. + + web_search + - Search the web using DuckDuckGo's API for general topics or keywords + web_scrape + - Fetch content from html websites and APIs + - Save as text, JSON, or binary files + - Content is cached locally for later use + - This is not optimised for complex websites, so don't use this as the first tool. + cache + - Manage your cached files + - List, view, delete files + - Clear all cached data + The extension automatically manages: + - Cache directory: {cache_dir} + - File organization and cleanup + "#, + cache_dir = cache_dir.display() + }; + + Self { + tools: vec![ + web_search_tool, + web_scrape_tool, + quick_script_tool, + computer_control_tool, + cache_tool, + ], + cache_dir, + active_resources: Arc::new(Mutex::new(HashMap::new())), + http_client: Client::builder().user_agent("Goose/1.0").build().unwrap(), + instructions: instructions.clone(), + } + } + + // Helper function to generate a cache file path + fn get_cache_path(&self, prefix: &str, extension: &str) -> PathBuf { + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + self.cache_dir + .join(format!("{}_{}.{}", prefix, timestamp, extension)) + } + + // Helper function to save content to cache + async fn save_to_cache( + &self, + content: &[u8], + prefix: &str, + extension: &str, + ) -> Result { + let cache_path = self.get_cache_path(prefix, extension); + fs::write(&cache_path, content) + .map_err(|e| ToolError::ExecutionError(format!("Failed to write to cache: {}", e)))?; + Ok(cache_path) + } + + // Helper function to register a file as a resource + fn register_as_resource(&self, cache_path: &PathBuf, mime_type: &str) -> Result<(), ToolError> { + let uri = Url::from_file_path(cache_path) + .map_err(|_| ToolError::ExecutionError("Invalid cache path".into()))? + .to_string(); + + let resource = Resource::new( + uri.clone(), + Some(mime_type.to_string()), + Some(cache_path.to_string_lossy().into_owned()), + ) + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + self.active_resources.lock().unwrap().insert(uri, resource); + Ok(()) + } + + // Implement web_scrape tool functionality + async fn web_search(&self, params: Value) -> Result, ToolError> { + let query = params + .get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'query' parameter".into()))?; + + // Create the DuckDuckGo API URL + let url = format!( + "https://api.duckduckgo.com/?q={}&format=json&pretty=1", + urlencoding::encode(query) + ); + + // Fetch the results + let response = self.http_client.get(&url).send().await.map_err(|e| { + ToolError::ExecutionError(format!("Failed to fetch search results: {}", e)) + })?; + + let status = response.status(); + if !status.is_success() { + return Err(ToolError::ExecutionError(format!( + "HTTP request failed with status: {}", + status + ))); + } + + // Get the JSON response + let json_text = response.text().await.map_err(|e| { + ToolError::ExecutionError(format!("Failed to get response text: {}", e)) + })?; + + // Save to cache + let cache_path = self + .save_to_cache(json_text.as_bytes(), "search", "json") + .await?; + + // Register as a resource + self.register_as_resource(&cache_path, "json")?; + + Ok(vec![Content::text(format!( + "Search results saved to: {}", + cache_path.display() + ))]) + } + + async fn web_scrape(&self, params: Value) -> Result, ToolError> { + let url = params + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'url' parameter".into()))?; + + let save_as = params + .get("save_as") + .and_then(|v| v.as_str()) + .unwrap_or("text"); + + // Fetch the content + let response = self + .http_client + .get(url) + .send() + .await + .map_err(|e| ToolError::ExecutionError(format!("Failed to fetch URL: {}", e)))?; + + let status = response.status(); + if !status.is_success() { + return Err(ToolError::ExecutionError(format!( + "HTTP request failed with status: {}", + status + ))); + } + + // Process based on save_as parameter + let (content, extension) = + match save_as { + "text" => { + let text = response.text().await.map_err(|e| { + ToolError::ExecutionError(format!("Failed to get text: {}", e)) + })?; + (text.into_bytes(), "txt") + } + "json" => { + let text = response.text().await.map_err(|e| { + ToolError::ExecutionError(format!("Failed to get text: {}", e)) + })?; + // Verify it's valid JSON + serde_json::from_str::(&text).map_err(|e| { + ToolError::ExecutionError(format!("Invalid JSON response: {}", e)) + })?; + (text.into_bytes(), "json") + } + "binary" => { + let bytes = response.bytes().await.map_err(|e| { + ToolError::ExecutionError(format!("Failed to get bytes: {}", e)) + })?; + (bytes.to_vec(), "bin") + } + _ => unreachable!(), // Prevented by enum in tool definition + }; + + // Save to cache + let cache_path = self.save_to_cache(&content, "web", extension).await?; + + // Register as a resource + self.register_as_resource(&cache_path, save_as)?; + + Ok(vec![Content::text(format!( + "Content saved to: {}", + cache_path.display() + ))]) + } + + // Implement quick_script tool functionality + async fn quick_script(&self, params: Value) -> Result, ToolError> { + let language = params + .get("language") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'language' parameter".into()))?; + + let script = params + .get("script") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'script' parameter".into()))?; + + let save_output = params + .get("save_output") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Create a temporary directory for the script + let script_dir = tempfile::tempdir().map_err(|e| { + ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e)) + })?; + + let command = match language { + "shell" => { + let script_path = script_dir.path().join("script.sh"); + fs::write(&script_path, script).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write script: {}", e)) + })?; + + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).map_err( + |e| { + ToolError::ExecutionError(format!( + "Failed to set script permissions: {}", + e + )) + }, + )?; + + script_path.display().to_string() + } + "ruby" => { + let script_path = script_dir.path().join("script.rb"); + fs::write(&script_path, script).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write script: {}", e)) + })?; + + format!("ruby {}", script_path.display()) + } + _ => unreachable!(), // Prevented by enum in tool definition + }; + + // Run the script + let output = Command::new("bash") + .arg("-c") + .arg(&command) + .output() + .await + .map_err(|e| ToolError::ExecutionError(format!("Failed to run script: {}", e)))?; + + let output_str = String::from_utf8_lossy(&output.stdout).into_owned(); + let error_str = String::from_utf8_lossy(&output.stderr).into_owned(); + + let mut result = if output.status.success() { + format!("Script completed successfully.\n\nOutput:\n{}", output_str) + } else { + format!( + "Script failed with error code {}.\n\nError:\n{}\nOutput:\n{}", + output.status, error_str, output_str + ) + }; + + // Save output if requested + if save_output && !output_str.is_empty() { + let cache_path = self + .save_to_cache(output_str.as_bytes(), "script_output", "txt") + .await?; + result.push_str(&format!("\n\nOutput saved to: {}", cache_path.display())); + + // Register as a resource + self.register_as_resource(&cache_path, "text")?; + } + + Ok(vec![Content::text(result)]) + } + + // Implement computer control (AppleScript) functionality + async fn computer_control(&self, params: Value) -> Result, ToolError> { + if std::env::consts::OS != "macos" { + return Err(ToolError::ExecutionError( + "Computer control (AppleScript) is only supported on macOS".into(), + )); + } + + let script = params + .get("script") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'script' parameter".into()))?; + + let save_output = params + .get("save_output") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Create a temporary directory for the script + let script_dir = tempfile::tempdir().map_err(|e| { + ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e)) + })?; + + let script_path = script_dir.path().join("script.scpt"); + fs::write(&script_path, script) + .map_err(|e| ToolError::ExecutionError(format!("Failed to write script: {}", e)))?; + + let command = format!("osascript {}", script_path.display()); + + // Run the script + let output = Command::new("bash") + .arg("-c") + .arg(&command) + .output() + .await + .map_err(|e| ToolError::ExecutionError(format!("Failed to run AppleScript: {}", e)))?; + + let output_str = String::from_utf8_lossy(&output.stdout).into_owned(); + let error_str = String::from_utf8_lossy(&output.stderr).into_owned(); + + let mut result = if output.status.success() { + format!( + "AppleScript completed successfully.\n\nOutput:\n{}", + output_str + ) + } else { + format!( + "AppleScript failed with error code {}.\n\nError:\n{}\nOutput:\n{}", + output.status, error_str, output_str + ) + }; + + // Save output if requested + if save_output && !output_str.is_empty() { + let cache_path = self + .save_to_cache(output_str.as_bytes(), "applescript_output", "txt") + .await?; + result.push_str(&format!("\n\nOutput saved to: {}", cache_path.display())); + + // Register as a resource + self.register_as_resource(&cache_path, "text")?; + } + + Ok(vec![Content::text(result)]) + } + + // Implement cache tool functionality + async fn cache(&self, params: Value) -> Result, ToolError> { + let command = params + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'command' parameter".into()))?; + + match command { + "list" => { + let mut files = Vec::new(); + for entry in fs::read_dir(&self.cache_dir).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read cache directory: {}", e)) + })? { + let entry = entry.map_err(|e| { + ToolError::ExecutionError(format!("Failed to read directory entry: {}", e)) + })?; + files.push(format!("{}", entry.path().display())); + } + files.sort(); + Ok(vec![Content::text(format!( + "Cached files:\n{}", + files.join("\n") + ))]) + } + "view" => { + let path = params.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + ToolError::InvalidParameters("Missing 'path' parameter for view".into()) + })?; + + let content = fs::read_to_string(path).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read file: {}", e)) + })?; + + Ok(vec![Content::text(format!( + "Content of {}:\n\n{}", + path, content + ))]) + } + "delete" => { + let path = params.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + ToolError::InvalidParameters("Missing 'path' parameter for delete".into()) + })?; + + fs::remove_file(path).map_err(|e| { + ToolError::ExecutionError(format!("Failed to delete file: {}", e)) + })?; + + // Remove from active resources if present + if let Ok(url) = Url::from_file_path(path) { + self.active_resources + .lock() + .unwrap() + .remove(&url.to_string()); + } + + Ok(vec![Content::text(format!("Deleted file: {}", path))]) + } + "clear" => { + fs::remove_dir_all(&self.cache_dir).map_err(|e| { + ToolError::ExecutionError(format!("Failed to clear cache directory: {}", e)) + })?; + fs::create_dir_all(&self.cache_dir).map_err(|e| { + ToolError::ExecutionError(format!("Failed to recreate cache directory: {}", e)) + })?; + + // Clear active resources + self.active_resources.lock().unwrap().clear(); + + Ok(vec![Content::text("Cache cleared successfully.")]) + } + _ => unreachable!(), // Prevented by enum in tool definition + } + } +} + +impl Router for NonDeveloperRouter { + fn name(&self) -> String { + "NonDeveloperExtension".to_string() + } + + fn instructions(&self) -> String { + self.instructions.clone() + } + + fn capabilities(&self) -> ServerCapabilities { + CapabilitiesBuilder::new() + .with_tools(false) + .with_resources(false, false) + .build() + } + + fn list_tools(&self) -> Vec { + self.tools.clone() + } + + fn call_tool( + &self, + tool_name: &str, + arguments: Value, + ) -> Pin, ToolError>> + Send + 'static>> { + let this = self.clone(); + let tool_name = tool_name.to_string(); + Box::pin(async move { + match tool_name.as_str() { + "web_search" => this.web_search(arguments).await, + "web_scrape" => this.web_scrape(arguments).await, + "automation_script" => this.quick_script(arguments).await, + "computer_control" => this.computer_control(arguments).await, + "cache" => this.cache(arguments).await, + _ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))), + } + }) + } + + fn list_resources(&self) -> Vec { + let active_resources = self.active_resources.lock().unwrap(); + let resources = active_resources.values().cloned().collect(); + tracing::info!("Listing resources: {:?}", resources); + resources + } + + fn read_resource( + &self, + uri: &str, + ) -> Pin> + Send + 'static>> { + let uri = uri.to_string(); + let this = self.clone(); + + Box::pin(async move { + let active_resources = this.active_resources.lock().unwrap(); + let resource = active_resources + .get(&uri) + .ok_or_else(|| ResourceError::NotFound(format!("Resource not found: {}", uri)))? + .clone(); + + let url = Url::parse(&uri) + .map_err(|e| ResourceError::NotFound(format!("Invalid URI: {}", e)))?; + + if url.scheme() != "file" { + return Err(ResourceError::NotFound( + "Only file:// URIs are supported".into(), + )); + } + + let path = url + .to_file_path() + .map_err(|_| ResourceError::NotFound("Invalid file path in URI".into()))?; + + match resource.mime_type.as_str() { + "text" | "json" => fs::read_to_string(&path).map_err(|e| { + ResourceError::ExecutionError(format!("Failed to read file: {}", e)) + }), + "binary" => { + let bytes = fs::read(&path).map_err(|e| { + ResourceError::ExecutionError(format!("Failed to read file: {}", e)) + })?; + Ok(base64::prelude::BASE64_STANDARD.encode(bytes)) + } + mime_type => Err(ResourceError::NotFound(format!( + "Unsupported mime type: {}", + mime_type + ))), + } + }) + } +} diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml new file mode 100644 index 00000000..0bbb8fd9 --- /dev/null +++ b/crates/goose-server/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "goose-server" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description.workspace = true + +[dependencies] +goose = { path = "../goose" } +mcp-core = { path = "../mcp-core" } +goose-mcp = { path = "../goose-mcp" } +mcp-server = { path = "../mcp-server" } +axum = { version = "0.7", features = ["ws"] } +tokio = { version = "1.0", features = ["full"] } +chrono = "0.4" +tower-http = { version = "0.5", features = ["cors"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +futures = "0.3" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] } +tracing-appender = "0.2" +tokio-stream = "0.1" +anyhow = "1.0" +bytes = "1.5" +http = "1.0" +config = { version = "0.14.1", features = ["toml"] } +thiserror = "1.0" +clap = { version = "4.4", features = ["derive"] } +once_cell = "1.18" + +[[bin]] +name = "goosed" +path = "src/main.rs" + +[dev-dependencies] +tower = "0.5" +async-trait = "0.1" \ No newline at end of file diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs new file mode 100644 index 00000000..32787623 --- /dev/null +++ b/crates/goose-server/src/commands/agent.rs @@ -0,0 +1,34 @@ +use crate::configuration; +use crate::state; +use anyhow::Result; +use tower_http::cors::{Any, CorsLayer}; +use tracing::info; + +pub async fn run() -> Result<()> { + // Initialize logging + crate::logging::setup_logging(Some("goosed"))?; + + // Load configuration + let settings = configuration::Settings::new()?; + + // load secret key from GOOSE_SERVER__SECRET_KEY environment variable + let secret_key = + std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string()); + + // Create app state - agent will start as None + let state = state::AppState::new(secret_key.clone()).await?; + + // Create router with CORS support + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = crate::routes::configure(state).layer(cors); + + // Run server + let listener = tokio::net::TcpListener::bind(settings.socket_addr()).await?; + info!("listening on {}", listener.local_addr()?); + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/crates/goose-server/src/commands/mcp.rs b/crates/goose-server/src/commands/mcp.rs new file mode 100644 index 00000000..95ef0b22 --- /dev/null +++ b/crates/goose-server/src/commands/mcp.rs @@ -0,0 +1,32 @@ +use anyhow::Result; +use goose_mcp::{ + DeveloperRouter, GoogleDriveRouter, JetBrainsRouter, MemoryRouter, NonDeveloperRouter, +}; +use mcp_server::router::RouterService; +use mcp_server::{BoundedService, ByteTransport, Server}; +use tokio::io::{stdin, stdout}; + +pub async fn run(name: &str) -> Result<()> { + // Initialize logging + crate::logging::setup_logging(Some(&format!("mcp-{name}")))?; + + tracing::info!("Starting MCP server"); + let router: Option> = match name { + "developer" => Some(Box::new(RouterService(DeveloperRouter::new()))), + "nondeveloper" => Some(Box::new(RouterService(NonDeveloperRouter::new()))), + "jetbrains" => Some(Box::new(RouterService(JetBrainsRouter::new()))), + "google_drive" => { + let router = GoogleDriveRouter::new().await; + Some(Box::new(RouterService(router))) + } + "memory" => Some(Box::new(RouterService(MemoryRouter::new()))), + _ => None, + }; + + // Create and run the server + let server = Server::new(router.unwrap_or_else(|| panic!("Unknown server requested {}", name))); + let transport = ByteTransport::new(stdin(), stdout()); + + tracing::info!("Server initialized and ready to handle requests"); + Ok(server.run(transport).await?) +} diff --git a/crates/goose-server/src/commands/mod.rs b/crates/goose-server/src/commands/mod.rs new file mode 100644 index 00000000..9de800de --- /dev/null +++ b/crates/goose-server/src/commands/mod.rs @@ -0,0 +1,2 @@ +pub mod agent; +pub mod mcp; diff --git a/crates/goose-server/src/configuration.rs b/crates/goose-server/src/configuration.rs new file mode 100644 index 00000000..f0f3349b --- /dev/null +++ b/crates/goose-server/src/configuration.rs @@ -0,0 +1,90 @@ +use crate::error::{to_env_var, ConfigError}; +use config::{Config, Environment}; +use serde::Deserialize; +use std::net::SocketAddr; + +#[derive(Debug, Default, Deserialize)] +pub struct Settings { + #[serde(default = "default_host")] + pub host: String, + #[serde(default = "default_port")] + pub port: u16, +} + +impl Settings { + pub fn socket_addr(&self) -> SocketAddr { + format!("{}:{}", self.host, self.port) + .parse() + .expect("Failed to parse socket address") + } + + pub fn new() -> Result { + Self::load_and_validate() + } + + fn load_and_validate() -> Result { + // Start with default configuration + let config = Config::builder() + // Server defaults + .set_default("host", default_host())? + .set_default("port", default_port())? + // Layer on the environment variables + .add_source( + Environment::with_prefix("GOOSE") + .prefix_separator("_") + .separator("__") + .try_parsing(true), + ) + .build()?; + + // Try to deserialize the configuration + let result: Result = config.try_deserialize(); + + // Handle missing field errors specially + match result { + Ok(settings) => Ok(settings), + Err(err) => { + tracing::debug!("Configuration error: {:?}", &err); + + // Handle both NotFound and missing field message variants + let error_str = err.to_string(); + if error_str.starts_with("missing field") { + // Extract field name from error message "missing field `type`" + let field = error_str + .trim_start_matches("missing field `") + .trim_end_matches("`"); + let env_var = to_env_var(field); + Err(ConfigError::MissingEnvVar { env_var }) + } else if let config::ConfigError::NotFound(field) = &err { + let env_var = to_env_var(field); + Err(ConfigError::MissingEnvVar { env_var }) + } else { + Err(ConfigError::Other(err)) + } + } + } + } +} + +fn default_host() -> String { + "127.0.0.1".to_string() +} + +fn default_port() -> u16 { + 3000 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_socket_addr_conversion() { + let server_settings = Settings { + host: "127.0.0.1".to_string(), + port: 3000, + }; + let addr = server_settings.socket_addr(); + assert_eq!(addr.to_string(), "127.0.0.1:3000"); + } +} diff --git a/crates/goose-server/src/error.rs b/crates/goose-server/src/error.rs new file mode 100644 index 00000000..5f38f85f --- /dev/null +++ b/crates/goose-server/src/error.rs @@ -0,0 +1,40 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Missing required environment variable: {env_var}")] + MissingEnvVar { env_var: String }, + #[error("Configuration error: {0}")] + Other(#[from] config::ConfigError), +} + +// Helper function to format environment variable names +pub(crate) fn to_env_var(field_path: &str) -> String { + // Handle nested fields by converting dots to double underscores + // If the field is in the provider object, we need to prefix it appropriately + let normalized_path = if field_path == "type" { + "provider.type".to_string() + } else if field_path.starts_with("provider.") { + field_path.to_string() + } else { + format!("provider.{}", field_path) + }; + + format!( + "GOOSE_{}", + normalized_path.replace('.', "__").to_uppercase() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_env_var_conversion() { + assert_eq!(to_env_var("type"), "GOOSE_PROVIDER__TYPE"); + assert_eq!(to_env_var("api_key"), "GOOSE_PROVIDER__API_KEY"); + assert_eq!(to_env_var("provider.host"), "GOOSE_PROVIDER__HOST"); + assert_eq!(to_env_var("provider.api_key"), "GOOSE_PROVIDER__API_KEY"); + } +} diff --git a/crates/goose-server/src/logging.rs b/crates/goose-server/src/logging.rs new file mode 100644 index 00000000..1077f251 --- /dev/null +++ b/crates/goose-server/src/logging.rs @@ -0,0 +1,106 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; +use tracing_appender::rolling::Rotation; +use tracing_subscriber::{ + filter::LevelFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, + Registry, +}; + +use goose::tracing::langfuse_layer; + +/// Returns the directory where log files should be stored. +/// Creates the directory structure if it doesn't exist. +fn get_log_directory() -> Result { + let home = std::env::var("HOME").context("HOME environment variable not set")?; + let base_log_dir = PathBuf::from(home) + .join(".config") + .join("goose") + .join("logs") + .join("server"); // Add server-specific subdirectory + + // Create date-based subdirectory + let now = chrono::Local::now(); + let date_dir = base_log_dir.join(now.format("%Y-%m-%d").to_string()); + + // Ensure log directory exists + fs::create_dir_all(&date_dir).context("Failed to create log directory")?; + + Ok(date_dir) +} + +/// Sets up the logging infrastructure for the application. +/// This includes: +/// - File-based logging with JSON formatting (DEBUG level) +/// - Console output for development (INFO level) +/// - Optional Langfuse integration (DEBUG level) +pub fn setup_logging(name: Option<&str>) -> Result<()> { + // Set up file appender for goose module logs + let log_dir = get_log_directory()?; + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string(); + + // Create log file name by prefixing with timestamp + let log_filename = if name.is_some() { + format!("{}-{}.log", timestamp, name.unwrap()) + } else { + format!("{}.log", timestamp) + }; + + // Create non-rolling file appender for detailed logs + let file_appender = + tracing_appender::rolling::RollingFileAppender::new(Rotation::NEVER, log_dir, log_filename); + + // Create JSON file logging layer + let file_layer = fmt::layer() + .with_target(true) + .with_level(true) + .with_writer(file_appender) + .with_ansi(false) + .with_file(true); + + // Create console logging layer for development - INFO and above only + let console_layer = fmt::layer() + .with_target(true) + .with_level(true) + .with_ansi(true) + .with_file(true) + .with_line_number(true) + .pretty(); + + // Base filter for all logging + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + // Set default levels for different modules + EnvFilter::new("") + // Set mcp-server module to DEBUG + .add_directive("mcp_server=debug".parse().unwrap()) + // Set mcp-client to DEBUG + .add_directive("mcp_client=debug".parse().unwrap()) + // Set goose module to DEBUG + .add_directive("goose=debug".parse().unwrap()) + // Set goose-server to INFO + .add_directive("goose_server=info".parse().unwrap()) + // Set tower-http to INFO for request logging + .add_directive("tower_http=info".parse().unwrap()) + // Set everything else to WARN + .add_directive(LevelFilter::WARN.into()) + }); + + // Build the subscriber with required layers + let subscriber = Registry::default() + .with(file_layer.with_filter(env_filter)) + .with(console_layer.with_filter(LevelFilter::INFO)); + + // Initialize with Langfuse if available + if let Some(langfuse) = langfuse_layer::create_langfuse_observer() { + subscriber + .with(langfuse.with_filter(LevelFilter::DEBUG)) + .try_init() + .context("Failed to set global subscriber")?; + } else { + subscriber + .try_init() + .context("Failed to set global subscriber")?; + } + + Ok(()) +} diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs new file mode 100644 index 00000000..4647c755 --- /dev/null +++ b/crates/goose-server/src/main.rs @@ -0,0 +1,43 @@ +mod commands; +mod configuration; +mod error; +mod logging; +mod routes; +mod state; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Run the agent server + Agent, + /// Run the MCP server + Mcp { + /// Name of the MCP server type + name: String, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + match &cli.command { + Commands::Agent => { + commands::agent::run().await?; + } + Commands::Mcp { name } => { + commands::mcp::run(name).await?; + } + } + + Ok(()) +} diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs new file mode 100644 index 00000000..dfd68307 --- /dev/null +++ b/crates/goose-server/src/routes/agent.rs @@ -0,0 +1,137 @@ +use crate::state::AppState; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + routing::{get, post}, + Json, Router, +}; +use goose::config::Config; +use goose::{agents::AgentFactory, model::ModelConfig, providers}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; + +#[derive(Serialize)] +struct VersionsResponse { + available_versions: Vec, + default_version: String, +} + +#[derive(Deserialize)] +struct CreateAgentRequest { + version: Option, + provider: String, + model: Option, +} + +#[derive(Serialize)] +struct CreateAgentResponse { + version: String, +} + +#[derive(Deserialize)] +struct ProviderFile { + name: String, + description: String, + models: Vec, + required_keys: Vec, +} + +#[derive(Serialize)] +struct ProviderDetails { + name: String, + description: String, + models: Vec, + required_keys: Vec, +} + +#[derive(Serialize)] +struct ProviderList { + id: String, + details: ProviderDetails, +} + +async fn get_versions() -> Json { + let versions = AgentFactory::available_versions(); + let default_version = AgentFactory::default_version().to_string(); + + Json(VersionsResponse { + available_versions: versions.iter().map(|v| v.to_string()).collect(), + default_version, + }) +} + +async fn create_agent( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result, StatusCode> { + // Verify secret key + let secret_key = headers + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if secret_key != state.secret_key { + return Err(StatusCode::UNAUTHORIZED); + } + + // Set the environment variable for the model if provided + if let Some(model) = &payload.model { + let env_var_key = format!("{}_MODEL", payload.provider.to_uppercase()); + env::set_var(env_var_key.clone(), model); + println!("Set environment variable: {}={}", env_var_key, model); + } + + let config = Config::global(); + let model = payload.model.unwrap_or_else(|| { + config + .get("GOOSE_MODEL") + .expect("Did not find a model on payload or in env") + }); + let model_config = ModelConfig::new(model); + let provider = + providers::create(&payload.provider, model_config).expect("Failed to create provider"); + + let version = payload + .version + .unwrap_or_else(|| AgentFactory::default_version().to_string()); + + let new_agent = AgentFactory::create(&version, provider).expect("Failed to create agent"); + + let mut agent = state.agent.lock().await; + *agent = Some(new_agent); + + Ok(Json(CreateAgentResponse { version })) +} + +async fn list_providers() -> Json> { + let contents = include_str!("providers_and_keys.json"); + + let providers: HashMap = + serde_json::from_str(contents).expect("Failed to parse providers_and_keys.json"); + + let response: Vec = providers + .into_iter() + .map(|(id, provider)| ProviderList { + id, + details: ProviderDetails { + name: provider.name, + description: provider.description, + models: provider.models, + required_keys: provider.required_keys, + }, + }) + .collect(); + + // Return the response as JSON. + Json(response) +} + +pub fn routes(state: AppState) -> Router { + Router::new() + .route("/agent/versions", get(get_versions)) + .route("/agent/providers", get(list_providers)) + .route("/agent", post(create_agent)) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/extension.rs b/crates/goose-server/src/routes/extension.rs new file mode 100644 index 00000000..1ab49234 --- /dev/null +++ b/crates/goose-server/src/routes/extension.rs @@ -0,0 +1,208 @@ +use std::collections::HashMap; + +use crate::state::AppState; +use axum::{extract::State, routing::post, Json, Router}; +use goose::{ + agents::{extension::Envs, ExtensionConfig}, + config::Config, +}; +use http::{HeaderMap, StatusCode}; +use serde::{Deserialize, Serialize}; + +/// Enum representing the different types of extension configuration requests. +#[derive(Deserialize)] +#[serde(tag = "type")] +enum ExtensionConfigRequest { + /// Server-Sent Events (SSE) extension. + #[serde(rename = "sse")] + Sse { + /// The name to identify this extension + name: String, + /// The URI endpoint for the SSE extension. + uri: String, + /// List of environment variable keys. The server will fetch their values from the keyring. + env_keys: Vec, + }, + /// Standard I/O (stdio) extension. + #[serde(rename = "stdio")] + Stdio { + /// The name to identify this extension + name: String, + /// The command to execute. + cmd: String, + /// Arguments for the command. + args: Vec, + /// List of environment variable keys. The server will fetch their values from the keyring. + env_keys: Vec, + }, + /// Built-in extension that is part of the goose binary. + #[serde(rename = "builtin")] + Builtin { + /// The name of the built-in extension. + name: String, + }, +} + +/// Response structure for adding an extension. +/// +/// - `error`: Indicates whether an error occurred (`true`) or not (`false`). +/// - `message`: Provides detailed error information when `error` is `true`. +#[derive(Serialize)] +struct ExtensionResponse { + error: bool, + message: Option, +} + +/// Handler for adding a new extension configuration. +async fn add_extension( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + // Verify the presence and validity of the secret key. + let secret_key = headers + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if secret_key != state.secret_key { + return Err(StatusCode::UNAUTHORIZED); + } + + // Load the configuration + let config = Config::global(); + + // Initialize a vector to collect any missing keys. + let mut missing_keys = Vec::new(); + + // Construct ExtensionConfig with Envs populated from keyring based on provided env_keys. + let extension_config: ExtensionConfig = match request { + ExtensionConfigRequest::Sse { + name, + uri, + env_keys, + } => { + let mut env_map = HashMap::new(); + for key in env_keys { + match config.get_secret(&key) { + Ok(value) => { + env_map.insert(key, value); + } + Err(_) => { + missing_keys.push(key); + } + } + } + + if !missing_keys.is_empty() { + return Ok(Json(ExtensionResponse { + error: true, + message: Some(format!( + "Missing secrets for keys: {}", + missing_keys.join(", ") + )), + })); + } + + ExtensionConfig::Sse { + name, + uri, + envs: Envs::new(env_map), + } + } + ExtensionConfigRequest::Stdio { + name, + cmd, + args, + env_keys, + } => { + let mut env_map = HashMap::new(); + for key in env_keys { + match config.get_secret(&key) { + Ok(value) => { + env_map.insert(key, value); + } + Err(_) => { + missing_keys.push(key); + } + } + } + + if !missing_keys.is_empty() { + return Ok(Json(ExtensionResponse { + error: true, + message: Some(format!( + "Missing secrets for keys: {}", + missing_keys.join(", ") + )), + })); + } + + ExtensionConfig::Stdio { + name, + cmd, + args, + envs: Envs::new(env_map), + } + } + ExtensionConfigRequest::Builtin { name } => ExtensionConfig::Builtin { name }, + }; + + // Acquire a lock on the agent and attempt to add the extension. + let mut agent = state.agent.lock().await; + let agent = agent.as_mut().ok_or(StatusCode::PRECONDITION_REQUIRED)?; + let response = agent.add_extension(extension_config).await; + + // Respond with the result. + match response { + Ok(_) => Ok(Json(ExtensionResponse { + error: false, + message: None, + })), + Err(e) => { + eprintln!("Failed to add extension configuration: {:?}", e); + Ok(Json(ExtensionResponse { + error: true, + message: Some(format!( + "Failed to add extension configuration, error: {:?}", + e + )), + })) + } + } +} + +/// Handler for removing an extension by name +async fn remove_extension( + State(state): State, + headers: HeaderMap, + Json(name): Json, +) -> Result, StatusCode> { + // Verify the presence and validity of the secret key + let secret_key = headers + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if secret_key != state.secret_key { + return Err(StatusCode::UNAUTHORIZED); + } + + // Acquire a lock on the agent and attempt to remove the extension + let mut agent = state.agent.lock().await; + let agent = agent.as_mut().ok_or(StatusCode::PRECONDITION_REQUIRED)?; + agent.remove_extension(&name).await; + + Ok(Json(ExtensionResponse { + error: false, + message: None, + })) +} + +/// Registers the extension management routes with the Axum router. +pub fn routes(state: AppState) -> Router { + Router::new() + .route("/extensions/add", post(add_extension)) + .route("/extensions/remove", post(remove_extension)) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/health.rs b/crates/goose-server/src/routes/health.rs new file mode 100644 index 00000000..aeed1692 --- /dev/null +++ b/crates/goose-server/src/routes/health.rs @@ -0,0 +1,17 @@ +use axum::{routing::get, Json, Router}; +use serde::Serialize; + +#[derive(Serialize)] +struct StatusResponse { + status: &'static str, +} + +/// Simple status endpoint that returns 200 OK when the server is running +async fn status() -> Json { + Json(StatusResponse { status: "ok" }) +} + +/// Configure health check routes +pub fn routes() -> Router { + Router::new().route("/status", get(status)) +} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs new file mode 100644 index 00000000..231c0242 --- /dev/null +++ b/crates/goose-server/src/routes/mod.rs @@ -0,0 +1,18 @@ +// Export route modules +pub mod agent; +pub mod extension; +pub mod health; +pub mod reply; +pub mod secrets; + +use axum::Router; + +// Function to configure all routes +pub fn configure(state: crate::state::AppState) -> Router { + Router::new() + .merge(health::routes()) + .merge(reply::routes(state.clone())) + .merge(agent::routes(state.clone())) + .merge(extension::routes(state.clone())) + .merge(secrets::routes(state)) +} diff --git a/crates/goose-server/src/routes/providers_and_keys.json b/crates/goose-server/src/routes/providers_and_keys.json new file mode 100644 index 00000000..54148a54 --- /dev/null +++ b/crates/goose-server/src/routes/providers_and_keys.json @@ -0,0 +1,44 @@ +{ + "openai": { + "name": "OpenAI", + "description": "Use GPT-4 and other OpenAI models", + "models": ["gpt-4o", "gpt-4-turbo","o1"], + "required_keys": ["OPENAI_API_KEY"] + }, + "anthropic": { + "name": "Anthropic", + "description": "Use Claude and other Anthropic models", + "models": ["claude-3.5-sonnet-2"], + "required_keys": ["ANTHROPIC_API_KEY"] + }, + "databricks": { + "name": "Databricks", + "description": "Connect to LLMs via Databricks", + "models": ["claude-3-5-sonnet-2"], + "required_keys": ["DATABRICKS_HOST"] + }, + "google": { + "name": "Google", + "description": "Lorem ipsum", + "models": ["gemini-1.5-flash"], + "required_keys": ["GOOGLE_API_KEY"] + }, + "grok": { + "name": "Groq", + "description": "Lorem ipsum", + "models": ["llama-3.3-70b-versatile"], + "required_keys": ["GROQ_API_KEY"] + }, + "ollama": { + "name": "Ollama", + "description": "Lorem ipsum", + "models": ["qwen2.5"], + "required_keys": [] + }, + "openrouter": { + "name": "OpenRouter", + "description": "Lorem ipsum", + "models": [], + "required_keys": ["OPENROUTER_API_KEY"] + } +} \ No newline at end of file diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs new file mode 100644 index 00000000..74077f2a --- /dev/null +++ b/crates/goose-server/src/routes/reply.rs @@ -0,0 +1,604 @@ +use crate::state::AppState; +use axum::{ + extract::State, + http::{self, HeaderMap, StatusCode}, + response::IntoResponse, + routing::post, + Json, Router, +}; +use bytes::Bytes; +use futures::{stream::StreamExt, Stream}; +use goose::message::{Message, MessageContent}; + +use mcp_core::{content::Content, role::Role}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{ + convert::Infallible, + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; +use tokio::sync::mpsc; +use tokio::time::timeout; +use tokio_stream::wrappers::ReceiverStream; + +// Types matching the incoming JSON structure +#[derive(Debug, Deserialize)] +struct ChatRequest { + messages: Vec, +} + +#[derive(Debug, Deserialize)] +struct IncomingMessage { + role: String, + content: String, + #[serde(default)] + #[serde(rename = "toolInvocations")] + tool_invocations: Vec, +} + +#[derive(Debug, Deserialize)] +struct ToolInvocation { + state: String, + #[serde(rename = "toolCallId")] + tool_call_id: String, + #[serde(rename = "toolName")] + tool_name: String, + args: Value, + result: Option>, +} + +// Custom SSE response type that implements the Vercel AI SDK protocol +pub struct SseResponse { + rx: ReceiverStream, +} + +impl SseResponse { + fn new(rx: ReceiverStream) -> Self { + Self { rx } + } +} + +impl Stream for SseResponse { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.rx) + .poll_next(cx) + .map(|opt| opt.map(|s| Ok(Bytes::from(s)))) + } +} + +impl IntoResponse for SseResponse { + fn into_response(self) -> axum::response::Response { + let stream = self; + let body = axum::body::Body::from_stream(stream); + + http::Response::builder() + .header("Content-Type", "text/event-stream") + .header("Cache-Control", "no-cache") + .header("Connection", "keep-alive") + .header("x-vercel-ai-data-stream", "v1") + .body(body) + .unwrap() + } +} + +// Convert incoming messages to our internal Message type +fn convert_messages(incoming: Vec) -> Vec { + let mut messages = Vec::new(); + + for msg in incoming { + match msg.role.as_str() { + "user" => { + messages.push(Message::user().with_text(msg.content)); + } + "assistant" => { + // First handle any tool invocations - each represents a complete request/response cycle + for tool in msg.tool_invocations { + if tool.state == "result" { + // Add the original tool request from assistant + let tool_call = mcp_core::tool::ToolCall { + name: tool.tool_name, + arguments: tool.args, + }; + messages.push( + Message::assistant() + .with_tool_request(tool.tool_call_id.clone(), Ok(tool_call)), + ); + + // Add the tool response from user + if let Some(result) = &tool.result { + messages.push( + Message::user() + .with_tool_response(tool.tool_call_id, Ok(result.clone())), + ); + } + } + } + + // Then add the assistant's text response after tool interactions + if !msg.content.is_empty() { + messages.push(Message::assistant().with_text(msg.content)); + } + } + _ => { + tracing::warn!("Unknown role: {}", msg.role); + } + } + } + + messages +} + +// Protocol-specific message formatting +struct ProtocolFormatter; + +impl ProtocolFormatter { + fn format_text(text: &str) -> String { + let encoded_text = serde_json::to_string(text).unwrap_or_else(|_| String::new()); + format!("0:{}\n", encoded_text) + } + + fn format_tool_call(id: &str, name: &str, args: &Value) -> String { + // Tool calls start with "9:" + let tool_call = json!({ + "toolCallId": id, + "toolName": name, + "args": args + }); + format!("9:{}\n", tool_call) + } + + fn format_tool_response(id: &str, result: &Vec) -> String { + // Tool responses start with "a:" + let response = json!({ + "toolCallId": id, + "result": result, + }); + format!("a:{}\n", response) + } + + fn format_error(error: &str) -> String { + // Error messages start with "3:" in the new protocol. + let encoded_error = serde_json::to_string(error).unwrap_or_else(|_| String::new()); + format!("3:{}\n", encoded_error) + } + + fn format_finish(reason: &str) -> String { + // Finish messages start with "d:" + let finish = json!({ + "finishReason": reason, + "usage": { + "promptTokens": 0, + "completionTokens": 0 + } + }); + format!("d:{}\n", finish) + } +} + +async fn stream_message( + message: Message, + tx: &mpsc::Sender, +) -> Result<(), mpsc::error::SendError> { + match message.role { + Role::User => { + // Handle tool responses + for content in message.content { + // I believe with the protocol we aren't intended to pass back user messages, so we only deal with + // the tool responses here + if let MessageContent::ToolResponse(response) = content { + // We should return a result for either an error or a success + match response.tool_result { + Ok(result) => { + tx.send(ProtocolFormatter::format_tool_response( + &response.id, + &result, + )) + .await?; + } + Err(err) => { + // Send an error message first + tx.send(ProtocolFormatter::format_error(&err.to_string())) + .await?; + // Then send an empty tool response to maintain the protocol + let result = + vec![Content::text(format!("Error: {}", err)).with_priority(0.0)]; + tx.send(ProtocolFormatter::format_tool_response( + &response.id, + &result, + )) + .await?; + } + } + } + } + } + Role::Assistant => { + for content in message.content { + match content { + MessageContent::ToolRequest(request) => { + match request.tool_call { + Ok(tool_call) => { + tx.send(ProtocolFormatter::format_tool_call( + &request.id, + &tool_call.name, + &tool_call.arguments, + )) + .await?; + } + Err(err) => { + // Send a placeholder tool call to maintain protocol + tx.send(ProtocolFormatter::format_tool_call( + &request.id, + "invalid_tool", + &json!({"error": err.to_string()}), + )) + .await?; + } + } + } + MessageContent::Text(text) => { + for line in text.text.lines() { + let modified_line = format!("{}\n", line); + tx.send(ProtocolFormatter::format_text(&modified_line)) + .await?; + } + } + MessageContent::Image(_) => { + // TODO + continue; + } + MessageContent::ToolResponse(_) => { + // Tool responses should only come from the user + continue; + } + } + } + } + } + Ok(()) +} + +async fn handler( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + // Verify secret key + let secret_key = headers + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if secret_key != state.secret_key { + return Err(StatusCode::UNAUTHORIZED); + } + + // Check protocol header (optional in our case) + if let Some(protocol) = headers.get("x-protocol") { + if protocol.to_str().map(|p| p != "data").unwrap_or(true) { + return Err(StatusCode::BAD_REQUEST); + } + } + + // Create channel for streaming + let (tx, rx) = mpsc::channel(100); + let stream = ReceiverStream::new(rx); + + // Convert incoming messages + let messages = convert_messages(request.messages); + + // Get a lock on the shared agent + let agent = state.agent.clone(); + + // Spawn task to handle streaming + tokio::spawn(async move { + let agent = agent.lock().await; + let agent = match agent.as_ref() { + Some(agent) => agent, + None => { + let _ = tx + .send(ProtocolFormatter::format_error("No agent configured")) + .await; + let _ = tx.send(ProtocolFormatter::format_finish("error")).await; + return; + } + }; + + let mut stream = match agent.reply(&messages).await { + Ok(stream) => stream, + Err(e) => { + tracing::error!("Failed to start reply stream: {}", e); + let _ = tx + .send(ProtocolFormatter::format_error(&e.to_string())) + .await; + let _ = tx.send(ProtocolFormatter::format_finish("error")).await; + return; + } + }; + + loop { + tokio::select! { + response = timeout(Duration::from_millis(500), stream.next()) => { + match response { + Ok(Some(Ok(message))) => { + if let Err(e) = stream_message(message, &tx).await { + tracing::error!("Error sending message through channel: {}", e); + let _ = tx.send(ProtocolFormatter::format_error(&e.to_string())).await; + break; + } + } + Ok(Some(Err(e))) => { + tracing::error!("Error processing message: {}", e); + let _ = tx.send(ProtocolFormatter::format_error(&e.to_string())).await; + break; + } + Ok(None) => { + break; + } + Err(_) => { // Heartbeat, used to detect disconnected clients and then end running tools. + if tx.is_closed() { + // Kill any running processes when the client disconnects + // TODO is this used? I suspect post MCP this is on the server instead + // goose::process_store::kill_processes(); + break; + } + continue; + } + } + } + } + } + + // Send finish message + let _ = tx.send(ProtocolFormatter::format_finish("stop")).await; + }); + + Ok(SseResponse::new(stream)) +} + +#[derive(Debug, Deserialize, serde::Serialize)] +struct AskRequest { + prompt: String, +} + +#[derive(Debug, serde::Serialize)] +struct AskResponse { + response: String, +} + +// simple ask an AI for a response, non streaming +async fn ask_handler( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + // Verify secret key + let secret_key = headers + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if secret_key != state.secret_key { + return Err(StatusCode::UNAUTHORIZED); + } + + let agent = state.agent.clone(); + let agent = agent.lock().await; + let agent = agent.as_ref().ok_or(StatusCode::NOT_FOUND)?; + + // Create a single message for the prompt + let messages = vec![Message::user().with_text(request.prompt)]; + + // Get response from agent + let mut response_text = String::new(); + let mut stream = match agent.reply(&messages).await { + Ok(stream) => stream, + Err(e) => { + tracing::error!("Failed to start reply stream: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + while let Some(response) = stream.next().await { + match response { + Ok(message) => { + if message.role == Role::Assistant { + for content in message.content { + if let MessageContent::Text(text) = content { + response_text.push_str(&text.text); + response_text.push('\n'); + } + } + } + } + Err(e) => { + tracing::error!("Error processing as_ai message: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + } + + Ok(Json(AskResponse { + response: response_text.trim().to_string(), + })) +} + +// Configure routes for this module +pub fn routes(state: AppState) -> Router { + Router::new() + .route("/reply", post(handler)) + .route("/ask", post(ask_handler)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use goose::{ + agents::AgentFactory, + model::ModelConfig, + providers::{ + base::{Provider, ProviderUsage, Usage}, + errors::ProviderError, + }, + }; + use mcp_core::tool::Tool; + + // Mock Provider implementation for testing + #[derive(Clone)] + struct MockProvider { + model_config: ModelConfig, + } + + #[async_trait::async_trait] + impl Provider for MockProvider { + fn metadata() -> goose::providers::base::ProviderMetadata { + goose::providers::base::ProviderMetadata::empty() + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message::assistant().with_text("Mock response"), + ProviderUsage::new("mock".to_string(), Usage::default()), + )) + } + } + + #[test] + fn test_convert_messages_user_only() { + let incoming = vec![IncomingMessage { + role: "user".to_string(), + content: "Hello".to_string(), + tool_invocations: vec![], + }]; + + let messages = convert_messages(incoming); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, Role::User); + assert!( + matches!(&messages[0].content[0], MessageContent::Text(text) if text.text == "Hello") + ); + } + + #[test] + fn test_convert_messages_with_tool_invocation() { + let tool_result = vec![Content::text("tool response").with_priority(0.0)]; + let incoming = vec![IncomingMessage { + role: "assistant".to_string(), + content: "".to_string(), + tool_invocations: vec![ToolInvocation { + state: "result".to_string(), + tool_call_id: "123".to_string(), + tool_name: "test_tool".to_string(), + args: json!({"key": "value"}), + result: Some(tool_result.clone()), + }], + }]; + + let messages = convert_messages(incoming); + assert_eq!(messages.len(), 2); // Tool request and response + + // Check tool request + assert_eq!(messages[0].role, Role::Assistant); + assert!( + matches!(&messages[0].content[0], MessageContent::ToolRequest(req) if req.id == "123") + ); + + // Check tool response + assert_eq!(messages[1].role, Role::User); + assert!( + matches!(&messages[1].content[0], MessageContent::ToolResponse(resp) if resp.id == "123") + ); + } + + #[test] + fn test_protocol_formatter() { + // Test text formatting + let text = "Hello world"; + let formatted = ProtocolFormatter::format_text(text); + assert_eq!(formatted, "0:\"Hello world\"\n"); + + // Test tool call formatting + let formatted = + ProtocolFormatter::format_tool_call("123", "test_tool", &json!({"key": "value"})); + assert!(formatted.starts_with("9:")); + assert!(formatted.contains("\"toolCallId\":\"123\"")); + assert!(formatted.contains("\"toolName\":\"test_tool\"")); + + // Test tool response formatting + let result = vec![Content::text("response").with_priority(0.0)]; + let formatted = ProtocolFormatter::format_tool_response("123", &result); + assert!(formatted.starts_with("a:")); + assert!(formatted.contains("\"toolCallId\":\"123\"")); + + // Test error formatting + let formatted = ProtocolFormatter::format_error("Test error"); + println!("Formatted error: {}", formatted); + assert!(formatted.starts_with("3:")); + assert!(formatted.contains("Test error")); + + // Test finish formatting + let formatted = ProtocolFormatter::format_finish("stop"); + assert!(formatted.starts_with("d:")); + assert!(formatted.contains("\"finishReason\":\"stop\"")); + } + + mod integration_tests { + use super::*; + use axum::{body::Body, http::Request}; + use std::sync::Arc; + use tokio::sync::Mutex; + use tower::ServiceExt; + + // This test requires tokio runtime + #[tokio::test] + async fn test_ask_endpoint() { + // Create a mock app state with mock provider + let mock_model_config = ModelConfig::new("test-model".to_string()); + let mock_provider = Box::new(MockProvider { + model_config: mock_model_config, + }); + let agent = AgentFactory::create("reference", mock_provider).unwrap(); + let state = AppState { + agent: Arc::new(Mutex::new(Some(agent))), + secret_key: "test-secret".to_string(), + }; + + // Build router + let app = routes(state); + + // Create request + let request = Request::builder() + .uri("/ask") + .method("POST") + .header("content-type", "application/json") + .header("x-secret-key", "test-secret") + .body(Body::from( + serde_json::to_string(&AskRequest { + prompt: "test prompt".to_string(), + }) + .unwrap(), + )) + .unwrap(); + + // Send request + let response = app.oneshot(request).await.unwrap(); + + // Assert response status + assert_eq!(response.status(), StatusCode::OK); + } + } +} diff --git a/crates/goose-server/src/routes/secrets.rs b/crates/goose-server/src/routes/secrets.rs new file mode 100644 index 00000000..03278e17 --- /dev/null +++ b/crates/goose-server/src/routes/secrets.rs @@ -0,0 +1,191 @@ +use crate::state::AppState; +use axum::{extract::State, routing::delete, routing::post, Json, Router}; +use goose::config::Config; +use http::{HeaderMap, StatusCode}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Serialize)] +struct SecretResponse { + error: bool, +} + +#[derive(Deserialize)] +struct SecretRequest { + key: String, + value: String, +} + +async fn store_secret( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + // Verify secret key + let secret_key = headers + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if secret_key != state.secret_key { + return Err(StatusCode::UNAUTHORIZED); + } + + match Config::global().set_secret(&request.key, Value::String(request.value)) { + Ok(_) => Ok(Json(SecretResponse { error: false })), + Err(_) => Ok(Json(SecretResponse { error: true })), + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderSecretRequest { + pub providers: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SecretStatus { + pub is_set: bool, + pub location: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderResponse { + pub supported: bool, + pub name: Option, + pub description: Option, + pub models: Option>, + pub secret_status: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ProviderConfig { + name: String, + description: String, + models: Vec, + required_keys: Vec, +} + +static PROVIDER_ENV_REQUIREMENTS: Lazy> = Lazy::new(|| { + let contents = include_str!("providers_and_keys.json"); + serde_json::from_str(contents).expect("Failed to parse providers_and_keys.json") +}); + +fn check_key_status(key: &str) -> (bool, Option) { + if let Ok(_value) = std::env::var(key) { + (true, Some("env".to_string())) + } else if Config::global().get_secret::(key).is_ok() { + (true, Some("keyring".to_string())) + } else { + (false, None) + } +} + +async fn check_provider_secrets( + Json(request): Json, +) -> Result>, StatusCode> { + let mut response = HashMap::new(); + + for provider_name in request.providers { + if let Some(provider_config) = PROVIDER_ENV_REQUIREMENTS.get(&provider_name) { + let mut secret_status = HashMap::new(); + + for key in &provider_config.required_keys { + let (key_set, key_location) = check_key_status(key); + secret_status.insert( + key.to_string(), + SecretStatus { + is_set: key_set, + location: key_location, + }, + ); + } + + response.insert( + provider_name, + ProviderResponse { + supported: true, + name: Some(provider_config.name.clone()), + description: Some(provider_config.description.clone()), + models: Some(provider_config.models.clone()), + secret_status, + }, + ); + } else { + response.insert( + provider_name, + ProviderResponse { + supported: false, + name: None, + description: None, + models: None, + secret_status: HashMap::new(), + }, + ); + } + } + + Ok(Json(response)) +} + +#[derive(Deserialize)] +struct DeleteSecretRequest { + key: String, +} + +async fn delete_secret( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + // Verify secret key + let secret_key = headers + .get("X-Secret-Key") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if secret_key != state.secret_key { + return Err(StatusCode::UNAUTHORIZED); + } + + // Attempt to delete the key + match Config::global().delete_secret(&request.key) { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(_) => Err(StatusCode::NOT_FOUND), + } +} + +pub fn routes(state: AppState) -> Router { + Router::new() + .route("/secrets/providers", post(check_provider_secrets)) + .route("/secrets/store", post(store_secret)) + .route("/secrets/delete", delete(delete_secret)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_unsupported_provider() { + // Setup + let request = ProviderSecretRequest { + providers: vec!["unsupported_provider".to_string()], + }; + + // Execute + let result = check_provider_secrets(Json(request)).await; + + // Assert + assert!(result.is_ok()); + let Json(response) = result.unwrap(); + + let provider_status = response + .get("unsupported_provider") + .expect("Provider should exist"); + assert!(!provider_status.supported); + assert!(provider_status.secret_status.is_empty()); + } +} diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs new file mode 100644 index 00000000..66269cc7 --- /dev/null +++ b/crates/goose-server/src/state.rs @@ -0,0 +1,21 @@ +use anyhow::Result; +use goose::agents::Agent; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Shared application state +#[allow(dead_code)] +#[derive(Clone)] +pub struct AppState { + pub agent: Arc>>>, + pub secret_key: String, +} + +impl AppState { + pub async fn new(secret_key: String) -> Result { + Ok(Self { + agent: Arc::new(Mutex::new(None)), + secret_key, + }) + } +} diff --git a/crates/goose/.gitignore b/crates/goose/.gitignore new file mode 100644 index 00000000..4c49bd78 --- /dev/null +++ b/crates/goose/.gitignore @@ -0,0 +1 @@ +.env diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml new file mode 100644 index 00000000..e1940075 --- /dev/null +++ b/crates/goose/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "goose" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description.workspace = true + +[build-dependencies] +tokio = { version = "1.36", features = ["full"] } +reqwest = { version = "0.12.9", features = ["json", "rustls-tls"], default-features = false } + +[dependencies] +mcp-client = { path = "../mcp-client" } +mcp-core = { path = "../mcp-core" } +anyhow = "1.0" +thiserror = "1.0" +futures = "0.3" +reqwest = { version = "0.12.9", features = [ + "rustls-tls", + "json", + "cookies", + "gzip", + "brotli", + "deflate", + "zstd", + "charset", + "http2", + "stream" + ], default-features = false } +tokio = { version = "1.0", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_urlencoded = "0.7" +uuid = { version = "1.0", features = ["v4"] } +regex = "1.11.1" +async-trait = "0.1" +async-stream = "0.3" +tera = "1.20.0" +tokenizers = "0.20.3" +include_dir = "0.7.4" +chrono = { version = "0.4.38", features = ["serde"] } +indoc = "2.0.5" +nanoid = "0.4" +sha2 = "0.10" +base64 = "0.21" +url = "2.5" +axum = "0.7" +webbrowser = "0.8" +dotenv = "0.15" +lazy_static = "1.5" +tracing = "0.1" +tracing-subscriber = "0.3" +wiremock = "0.6.0" +keyring = { version = "3.6.1", features = ["apple-native", "windows-native", "sync-secret-service"] } +ctor = "0.2.7" +paste = "1.0" +serde_yaml = "0.9.34" +once_cell = "1.20.2" +dirs = "6.0.0" + +[dev-dependencies] +criterion = "0.5" +tempfile = "3.15.0" +serial_test = "3.2.0" + +[[example]] +name = "agent" +path = "examples/agent.rs" + +[[example]] +name = "databricks_oauth" +path = "examples/databricks_oauth.rs" + +[[bench]] +name = "tokenization_benchmark" +harness = false diff --git a/crates/goose/benches/tokenization_benchmark.rs b/crates/goose/benches/tokenization_benchmark.rs new file mode 100644 index 00000000..708cc68b --- /dev/null +++ b/crates/goose/benches/tokenization_benchmark.rs @@ -0,0 +1,20 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use goose::token_counter::TokenCounter; + +fn benchmark_tokenization(c: &mut Criterion) { + let lengths = [1_000, 5_000, 10_000, 50_000, 100_000, 124_000, 200_000]; + let tokenizer_names = ["Xenova--gpt-4o", "Xenova--claude-tokenizer"]; + + for tokenizer_name in tokenizer_names { + let counter = TokenCounter::new(tokenizer_name); + for &length in &lengths { + let text = "hello ".repeat(length); + c.bench_function(&format!("{}_{}_tokens", tokenizer_name, length), |b| { + b.iter(|| counter.count_tokens(black_box(&text))) + }); + } + } +} + +criterion_group!(benches, benchmark_tokenization); +criterion_main!(benches); diff --git a/crates/goose/build.rs b/crates/goose/build.rs new file mode 100644 index 00000000..446a3d48 --- /dev/null +++ b/crates/goose/build.rs @@ -0,0 +1,53 @@ +use std::error::Error; +use std::fs; +use std::path::Path; + +const BASE_DIR: &str = "../../tokenizer_files"; +const TOKENIZERS: &[&str] = &["Xenova/gpt-4o", "Xenova/claude-tokenizer"]; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create base directory + fs::create_dir_all(BASE_DIR)?; + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed={}", BASE_DIR); + + for tokenizer_name in TOKENIZERS { + download_tokenizer(tokenizer_name).await?; + } + + Ok(()) +} + +async fn download_tokenizer(repo_id: &str) -> Result<(), Box> { + let dir_name = repo_id.replace('/', "--"); + let download_dir = format!("{}/{}", BASE_DIR, dir_name); + let file_url = format!( + "https://huggingface.co/{}/resolve/main/tokenizer.json", + repo_id + ); + let file_path = format!("{}/tokenizer.json", download_dir); + + // Create directory if it doesn't exist + fs::create_dir_all(&download_dir)?; + + // Check if file already exists + if Path::new(&file_path).exists() { + println!("Tokenizer for {} already exists, skipping...", repo_id); + return Ok(()); + } + + println!("Downloading tokenizer for {}...", repo_id); + + // Download the file + let response = reqwest::get(&file_url).await?; + if !response.status().is_success() { + return Err(format!("Failed to download tokenizer for {}", repo_id).into()); + } + + let content = response.bytes().await?; + fs::write(&file_path, content)?; + + println!("Downloaded {} to {}", repo_id, file_path); + Ok(()) +} diff --git a/crates/goose/examples/agent.rs b/crates/goose/examples/agent.rs new file mode 100644 index 00000000..6ecd4477 --- /dev/null +++ b/crates/goose/examples/agent.rs @@ -0,0 +1,36 @@ +use dotenv::dotenv; +use futures::StreamExt; +use goose::agents::{AgentFactory, ExtensionConfig}; +use goose::message::Message; +use goose::providers::databricks::DatabricksProvider; + +#[tokio::main] +async fn main() { + // Setup a model provider from env vars + let _ = dotenv(); + + let provider = Box::new(DatabricksProvider::default()); + + // Setup an agent with the developer extension + let mut agent = AgentFactory::create("reference", provider).expect("default should exist"); + + let config = ExtensionConfig::stdio("developer", "./target/debug/developer"); + agent.add_extension(config).await.unwrap(); + + println!("Extensions:"); + for extension in agent.list_extensions().await { + println!(" {}", extension); + } + + let messages = vec![Message::user() + .with_text("can you summarize the readme.md in this dir using just a haiku?")]; + + let mut stream = agent.reply(&messages).await.unwrap(); + while let Some(message) = stream.next().await { + println!( + "{}", + serde_json::to_string_pretty(&message.unwrap()).unwrap() + ); + println!("\n"); + } +} diff --git a/crates/goose/examples/databricks_oauth.rs b/crates/goose/examples/databricks_oauth.rs new file mode 100644 index 00000000..2b49cc03 --- /dev/null +++ b/crates/goose/examples/databricks_oauth.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use dotenv::dotenv; +use goose::{ + message::Message, + providers::{base::Provider, databricks::DatabricksProvider}, +}; + +#[tokio::main] +async fn main() -> Result<()> { + // Load environment variables from .env file + dotenv().ok(); + + // Clear any token to force OAuth + std::env::remove_var("DATABRICKS_TOKEN"); + + // Create the provider + let provider = DatabricksProvider::default(); + + // Create a simple message + let message = Message::user().with_text("Tell me a short joke about programming."); + + // Get a response + let (response, usage) = provider + .complete("You are a helpful assistant.", &[message], &[]) + .await?; + + // Print the response and usage statistics + println!("\nResponse from AI:"); + println!("---------------"); + for content in response.content { + dbg!(content); + } + println!("\nToken Usage:"); + println!("------------"); + println!("Input tokens: {:?}", usage.usage.input_tokens); + println!("Output tokens: {:?}", usage.usage.output_tokens); + println!("Total tokens: {:?}", usage.usage.total_tokens); + + Ok(()) +} diff --git a/crates/goose/examples/image_tool.rs b/crates/goose/examples/image_tool.rs new file mode 100644 index 00000000..f3b5d94a --- /dev/null +++ b/crates/goose/examples/image_tool.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use dotenv::dotenv; +use goose::{ + message::Message, + providers::{databricks::DatabricksProvider, openai::OpenAiProvider}, +}; +use mcp_core::{ + content::Content, + tool::{Tool, ToolCall}, +}; +use serde_json::json; +use std::fs; + +#[tokio::main] +async fn main() -> Result<()> { + // Load environment variables from .env file + dotenv().ok(); + + // Create providers + let providers: Vec> = vec![ + Box::new(DatabricksProvider::default()), + Box::new(OpenAiProvider::default()), + ]; + + for provider in providers { + // Read and encode test image + let image_data = fs::read("crates/goose/examples/test_assets/test_image.png")?; + let base64_image = BASE64.encode(image_data); + + // Create a message sequence that includes a tool response with both text and image + let messages = vec![ + Message::user().with_text("Read the image at ./test_image.png please"), + Message::assistant().with_tool_request( + "000", + Ok(ToolCall::new( + "view_image", + json!({"path": "./test_image.png"}), + )), + ), + Message::user() + .with_tool_response("000", Ok(vec![Content::image(base64_image, "image/png")])), + ]; + + // Get a response from the model about the image + let input_schema = json!({ + "type": "object", + "required": ["path"], + "properties": { + "path": { + "type": "string", + "default": null, + "description": "The path to the image" + }, + } + }); + let (response, usage) = provider + .complete( + "You are a helpful assistant. Please describe any text you see in the image.", + &messages, + &[Tool::new("view_image", "View an image", input_schema)], + ) + .await?; + + // Print the response and usage statistics + println!("\nResponse from AI:"); + println!("---------------"); + for content in response.content { + println!("{:?}", content); + } + println!("\nToken Usage:"); + println!("------------"); + println!("Input tokens: {:?}", usage.usage.input_tokens); + println!("Output tokens: {:?}", usage.usage.output_tokens); + println!("Total tokens: {:?}", usage.usage.total_tokens); + } + + Ok(()) +} diff --git a/crates/goose/examples/test_assets/test_image.png b/crates/goose/examples/test_assets/test_image.png new file mode 100644 index 00000000..f72b6598 Binary files /dev/null and b/crates/goose/examples/test_assets/test_image.png differ diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs new file mode 100644 index 00000000..6a595ffb --- /dev/null +++ b/crates/goose/src/agents/agent.rs @@ -0,0 +1,31 @@ +use anyhow::Result; +use async_trait::async_trait; +use futures::stream::BoxStream; +use serde_json::Value; + +use super::extension::{ExtensionConfig, ExtensionResult}; +use crate::message::Message; +use crate::providers::base::ProviderUsage; + +/// Core trait defining the behavior of an Agent +#[async_trait] +pub trait Agent: Send + Sync { + /// Create a stream that yields each message as it's generated by the agent + async fn reply(&self, messages: &[Message]) -> Result>>; + + /// Add a new MCP client to the agent + async fn add_extension(&mut self, config: ExtensionConfig) -> ExtensionResult<()>; + + /// Remove an extension by name + async fn remove_extension(&mut self, name: &str); + + /// List all extensions + // TODO this needs to also include status so we can tell if extensions are dropped + async fn list_extensions(&self) -> Vec; + + /// Pass through a JSON-RPC request to a specific extension + async fn passthrough(&self, extension: &str, request: Value) -> ExtensionResult; + + /// Get the total usage of the agent + async fn usage(&self) -> Vec; +} diff --git a/crates/goose/src/agents/capabilities.rs b/crates/goose/src/agents/capabilities.rs new file mode 100644 index 00000000..9a01a6e9 --- /dev/null +++ b/crates/goose/src/agents/capabilities.rs @@ -0,0 +1,734 @@ +use chrono::{DateTime, TimeZone, Utc}; +use futures::stream::{FuturesUnordered, StreamExt}; +use mcp_client::McpService; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::sync::LazyLock; +use std::time::Duration; +use tokio::sync::Mutex; +use tracing::{debug, instrument}; + +use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult}; +use crate::prompt_template::load_prompt_file; +use crate::providers::base::{Provider, ProviderUsage}; +use mcp_client::client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait}; +use mcp_client::transport::{SseTransport, StdioTransport, Transport}; +use mcp_core::{Content, Tool, ToolCall, ToolError, ToolResult}; +use serde_json::Value; + +// By default, we set it to Jan 1, 2020 if the resource does not have a timestamp +// This is to ensure that the resource is considered less important than resources with a more recent timestamp +static DEFAULT_TIMESTAMP: LazyLock> = + LazyLock::new(|| Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()); + +type McpClientBox = Arc>>; + +/// Manages MCP clients and their interactions +pub struct Capabilities { + clients: HashMap, + instructions: HashMap, + resource_capable_extensions: HashSet, + provider: Box, + provider_usage: Mutex>, +} + +/// A flattened representation of a resource used by the agent to prepare inference +#[derive(Debug, Clone)] +pub struct ResourceItem { + pub client_name: String, // The name of the client that owns the resource + pub uri: String, // The URI of the resource + pub name: String, // The name of the resource + pub content: String, // The content of the resource + pub timestamp: DateTime, // The timestamp of the resource + pub priority: f32, // The priority of the resource + pub token_count: Option, // The token count of the resource (filled in by the agent) +} + +impl ResourceItem { + pub fn new( + client_name: String, + uri: String, + name: String, + content: String, + timestamp: DateTime, + priority: f32, + ) -> Self { + Self { + client_name, + uri, + name, + content, + timestamp, + priority, + token_count: None, + } + } +} + +/// Sanitizes a string by replacing invalid characters with underscores. +/// Valid characters match [a-zA-Z0-9_-] +fn normalize(input: String) -> String { + let mut result = String::with_capacity(input.len()); + for c in input.chars() { + result.push(match c { + c if c.is_ascii_alphanumeric() || c == '_' || c == '-' => c, + c if c.is_whitespace() => continue, // effectively "strip" whitespace + _ => '_', // Replace any other non-ASCII character with '_' + }); + } + result.to_lowercase() +} + +impl Capabilities { + /// Create a new Capabilities with the specified provider + pub fn new(provider: Box) -> Self { + Self { + clients: HashMap::new(), + instructions: HashMap::new(), + resource_capable_extensions: HashSet::new(), + provider, + provider_usage: Mutex::new(Vec::new()), + } + } + + pub fn supports_resources(&self) -> bool { + !self.resource_capable_extensions.is_empty() + } + + /// Add a new MCP extension based on the provided client type + // TODO IMPORTANT need to ensure this times out if the extension command is broken! + pub async fn add_extension(&mut self, config: ExtensionConfig) -> ExtensionResult<()> { + let mut client: Box = match &config { + ExtensionConfig::Sse { uri, envs, .. } => { + let transport = SseTransport::new(uri, envs.get_env()); + let handle = transport.start().await?; + let service = McpService::with_timeout(handle, Duration::from_secs(300)); + Box::new(McpClient::new(service)) + } + ExtensionConfig::Stdio { + cmd, args, envs, .. + } => { + let transport = StdioTransport::new(cmd, args.to_vec(), envs.get_env()); + let handle = transport.start().await?; + let service = McpService::with_timeout(handle, Duration::from_secs(300)); + Box::new(McpClient::new(service)) + } + ExtensionConfig::Builtin { name } => { + // For builtin extensions, we run the current executable with mcp and extension name + let cmd = std::env::current_exe() + .expect("should find the current executable") + .to_str() + .expect("should resolve executable to string path") + .to_string(); + let transport = StdioTransport::new( + &cmd, + vec!["mcp".to_string(), name.clone()], + HashMap::new(), + ); + let handle = transport.start().await?; + let service = McpService::with_timeout(handle, Duration::from_secs(300)); + Box::new(McpClient::new(service)) + } + }; + + // Initialize the client with default capabilities + let info = ClientInfo { + name: "goose".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + let capabilities = ClientCapabilities::default(); + + let init_result = client + .initialize(info, capabilities) + .await + .map_err(|e| ExtensionError::Initialization(config.clone(), e))?; + + let sanitized_name = normalize(config.name().to_string()); + + // Store instructions if provided + if let Some(instructions) = init_result.instructions { + self.instructions + .insert(sanitized_name.clone(), instructions); + } + + // if the server is capable if resources we track it + if init_result.capabilities.resources.is_some() { + self.resource_capable_extensions + .insert(sanitized_name.clone()); + } + + // Store the client using the provided name + self.clients + .insert(sanitized_name.clone(), Arc::new(Mutex::new(client))); + + Ok(()) + } + + /// Get a reference to the provider + pub fn provider(&self) -> &dyn Provider { + &*self.provider + } + + /// Record provider usage + // TODO consider moving this off to the provider or as a form of logging + pub async fn record_usage(&self, usage: ProviderUsage) { + self.provider_usage.lock().await.push(usage); + } + + /// Get aggregated usage statistics + pub async fn remove_extension(&mut self, name: &str) -> ExtensionResult<()> { + let sanitized_name = normalize(name.to_string()); + + self.clients.remove(&sanitized_name); + self.instructions.remove(&sanitized_name); + self.resource_capable_extensions.remove(&sanitized_name); + Ok(()) + } + + pub async fn list_extensions(&self) -> ExtensionResult> { + Ok(self.clients.keys().cloned().collect()) + } + + pub async fn get_usage(&self) -> Vec { + let provider_usage = self.provider_usage.lock().await.clone(); + let mut usage_map: HashMap = HashMap::new(); + + provider_usage.iter().for_each(|usage| { + usage_map + .entry(usage.model.clone()) + .and_modify(|e| { + e.usage.input_tokens = Some( + e.usage.input_tokens.unwrap_or(0) + usage.usage.input_tokens.unwrap_or(0), + ); + e.usage.output_tokens = Some( + e.usage.output_tokens.unwrap_or(0) + usage.usage.output_tokens.unwrap_or(0), + ); + e.usage.total_tokens = Some( + e.usage.total_tokens.unwrap_or(0) + usage.usage.total_tokens.unwrap_or(0), + ); + }) + .or_insert_with(|| usage.clone()); + }); + usage_map.into_values().collect() + } + + /// Get all tools from all clients with proper prefixing + pub async fn get_prefixed_tools(&mut self) -> ExtensionResult> { + let mut tools = Vec::new(); + for (name, client) in &self.clients { + let client_guard = client.lock().await; + let mut client_tools = client_guard.list_tools(None).await?; + + loop { + for tool in client_tools.tools { + tools.push(Tool::new( + format!("{}__{}", name, tool.name), + &tool.description, + tool.input_schema, + )); + } + + // exit loop when there are no more pages + if client_tools.next_cursor.is_none() { + break; + } + + client_tools = client_guard.list_tools(client_tools.next_cursor).await?; + } + } + Ok(tools) + } + + /// Get client resources and their contents + pub async fn get_resources(&self) -> ExtensionResult> { + let mut result: Vec = Vec::new(); + + for (name, client) in &self.clients { + let client_guard = client.lock().await; + let resources = client_guard.list_resources(None).await?; + + for resource in resources.resources { + // Skip reading the resource if it's not marked active + // This avoids blowing up the context with inactive resources + if !resource.is_active() { + continue; + } + + if let Ok(contents) = client_guard.read_resource(&resource.uri).await { + for content in contents.contents { + let (uri, content_str) = match content { + mcp_core::resource::ResourceContents::TextResourceContents { + uri, + text, + .. + } => (uri, text), + mcp_core::resource::ResourceContents::BlobResourceContents { + uri, + blob, + .. + } => (uri, blob), + }; + + result.push(ResourceItem::new( + name.clone(), + uri, + resource.name.clone(), + content_str, + resource.timestamp().unwrap_or(*DEFAULT_TIMESTAMP), + resource.priority().unwrap_or(0.0), + )); + } + } + } + } + Ok(result) + } + + /// Get the extension prompt including client instructions + pub async fn get_system_prompt(&self) -> String { + let mut context: HashMap<&str, Vec> = HashMap::new(); + let extensions_info: Vec = self + .clients + .keys() + .map(|name| { + let instructions = self.instructions.get(name).cloned().unwrap_or_default(); + let has_resources = self.resource_capable_extensions.contains(name); + ExtensionInfo::new(name, &instructions, has_resources) + }) + .collect(); + + context.insert("extensions", extensions_info); + load_prompt_file("system.md", &context).expect("Prompt should render") + } + + /// Find and return a reference to the appropriate client for a tool call + fn get_client_for_tool(&self, prefixed_name: &str) -> Option<(&str, McpClientBox)> { + self.clients + .iter() + .find(|(key, _)| prefixed_name.starts_with(*key)) + .map(|(name, client)| (name.as_str(), Arc::clone(client))) + } + + // Function that gets executed for read_resource tool + async fn read_resource(&self, params: Value) -> Result, ToolError> { + let uri = params + .get("uri") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("Missing 'uri' parameter".to_string()))?; + + let extension_name = params.get("extension_name").and_then(|v| v.as_str()); + + // If extension name is provided, we can just look it up + if extension_name.is_some() { + let result = self + .read_resource_from_extension(uri, extension_name.unwrap()) + .await?; + return Ok(result); + } + + // If extension name is not provided, we need to search for the resource across all extensions + // Loop through each extension and try to read the resource, don't raise an error if the resource is not found + // TODO: do we want to find if a provided uri is in multiple extensions? + // currently it will return the first match and skip any others + for extension_name in self.resource_capable_extensions.iter() { + let result = self.read_resource_from_extension(uri, extension_name).await; + match result { + Ok(result) => return Ok(result), + Err(_) => continue, + } + } + + // None of the extensions had the resource so we raise an error + let available_extensions = self + .clients + .keys() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + let error_msg = format!( + "Resource with uri '{}' not found. Here are the available extensions: {}", + uri, available_extensions + ); + + Err(ToolError::InvalidParameters(error_msg)) + } + + async fn read_resource_from_extension( + &self, + uri: &str, + extension_name: &str, + ) -> Result, ToolError> { + let available_extensions = self + .clients + .keys() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + let error_msg = format!( + "Extension '{}' not found. Here are the available extensions: {}", + extension_name, available_extensions + ); + + let client = self + .clients + .get(extension_name) + .ok_or(ToolError::InvalidParameters(error_msg))?; + + let client_guard = client.lock().await; + let read_result = client_guard.read_resource(uri).await.map_err(|_| { + ToolError::ExecutionError(format!("Could not read resource with uri: {}", uri)) + })?; + + let mut result = Vec::new(); + for content in read_result.contents { + // Only reading the text resource content; skipping the blob content cause it's too long + if let mcp_core::resource::ResourceContents::TextResourceContents { text, .. } = content + { + let content_str = format!("{}\n\n{}", uri, text); + result.push(Content::text(content_str)); + } + } + + Ok(result) + } + + async fn list_resources_from_extension( + &self, + extension_name: &str, + ) -> Result, ToolError> { + let client = self.clients.get(extension_name).ok_or_else(|| { + ToolError::InvalidParameters(format!("Extension {} is not valid", extension_name)) + })?; + + let client_guard = client.lock().await; + client_guard + .list_resources(None) + .await + .map_err(|e| { + ToolError::ExecutionError(format!( + "Unable to list resources for {}, {:?}", + extension_name, e + )) + }) + .map(|lr| { + let resource_list = lr + .resources + .into_iter() + .map(|r| format!("{} - {}, uri: ({})", extension_name, r.name, r.uri)) + .collect::>() + .join("\n"); + + vec![Content::text(resource_list)] + }) + } + + async fn list_resources(&self, params: Value) -> Result, ToolError> { + let extension = params.get("extension").and_then(|v| v.as_str()); + + match extension { + Some(extension_name) => { + // Handle single extension case + self.list_resources_from_extension(extension_name).await + } + None => { + // Handle all extensions case using FuturesUnordered + let mut futures = FuturesUnordered::new(); + + // Create futures for each resource_capable_extension + for extension_name in &self.resource_capable_extensions { + futures.push(async move { + self.list_resources_from_extension(extension_name).await + }); + } + + let mut all_resources = Vec::new(); + let mut errors = Vec::new(); + + // Process results as they complete + while let Some(result) = futures.next().await { + match result { + Ok(content) => { + all_resources.extend(content); + } + Err(tool_error) => { + errors.push(tool_error); + } + } + } + + // Log any errors that occurred + if !errors.is_empty() { + tracing::error!( + errors = ?errors + .into_iter() + .map(|e| format!("{:?}", e)) + .collect::>(), + "errors from listing resources" + ); + } + + Ok(all_resources) + } + } + } + + /// Dispatch a single tool call to the appropriate client + #[instrument(skip(self, tool_call), fields(input, output))] + pub async fn dispatch_tool_call(&self, tool_call: ToolCall) -> ToolResult> { + let result = if tool_call.name == "platform__read_resource" { + // Check if the tool is read_resource and handle it separately + self.read_resource(tool_call.arguments.clone()).await + } else if tool_call.name == "platform__list_resources" { + self.list_resources(tool_call.arguments.clone()).await + } else { + // Else, dispatch tool call based on the prefix naming convention + let (client_name, client) = self + .get_client_for_tool(&tool_call.name) + .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))?; + + // rsplit returns the iterator in reverse, tool_name is then at 0 + let tool_name = tool_call + .name + .strip_prefix(client_name) + .and_then(|s| s.strip_prefix("__")) + .ok_or_else(|| ToolError::NotFound(tool_call.name.clone()))?; + + let client_guard = client.lock().await; + + client_guard + .call_tool(tool_name, tool_call.clone().arguments) + .await + .map(|result| result.content) + .map_err(|e| ToolError::ExecutionError(e.to_string())) + }; + + debug!( + "input" = serde_json::to_string(&tool_call).unwrap(), + "output" = serde_json::to_string(&result).unwrap(), + ); + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::Message; + use crate::model::ModelConfig; + use crate::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; + use crate::providers::errors::ProviderError; + use mcp_client::client::Error; + use mcp_client::client::McpClientTrait; + use mcp_core::protocol::{ + CallToolResult, InitializeResult, ListResourcesResult, ListToolsResult, ReadResourceResult, + }; + use serde_json::json; + + // Mock Provider implementation for testing + #[derive(Clone)] + struct MockProvider { + model_config: ModelConfig, + } + + #[async_trait::async_trait] + impl Provider for MockProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::empty() + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message::assistant().with_text("Mock response"), + ProviderUsage::new("mock".to_string(), Usage::default()), + )) + } + } + + struct MockClient {} + + #[async_trait::async_trait] + impl McpClientTrait for MockClient { + async fn initialize( + &mut self, + _info: ClientInfo, + _capabilities: ClientCapabilities, + ) -> Result { + Err(Error::NotInitialized) + } + + async fn list_resources( + &self, + _next_cursor: Option, + ) -> Result { + Err(Error::NotInitialized) + } + + async fn read_resource(&self, _uri: &str) -> Result { + Err(Error::NotInitialized) + } + + async fn list_tools(&self, _next_cursor: Option) -> Result { + Err(Error::NotInitialized) + } + + async fn call_tool(&self, name: &str, _arguments: Value) -> Result { + match name { + "tool" | "test__tool" => Ok(CallToolResult { + content: vec![], + is_error: None, + }), + _ => Err(Error::NotInitialized), + } + } + } + + #[test] + fn test_get_client_for_tool() { + let mock_model_config = + ModelConfig::new("test-model".to_string()).with_context_limit(200_000.into()); + + let mut capabilities = Capabilities::new(Box::new(MockProvider { + model_config: mock_model_config, + })); + + // Add some mock clients + capabilities.clients.insert( + normalize("test_client".to_string()), + Arc::new(Mutex::new(Box::new(MockClient {}))), + ); + + capabilities.clients.insert( + normalize("__client".to_string()), + Arc::new(Mutex::new(Box::new(MockClient {}))), + ); + + capabilities.clients.insert( + normalize("__cli__ent__".to_string()), + Arc::new(Mutex::new(Box::new(MockClient {}))), + ); + + capabilities.clients.insert( + normalize("client πŸš€".to_string()), + Arc::new(Mutex::new(Box::new(MockClient {}))), + ); + + // Test basic case + assert!(capabilities + .get_client_for_tool("test_client__tool") + .is_some()); + + // Test leading underscores + assert!(capabilities.get_client_for_tool("__client__tool").is_some()); + + // Test multiple underscores in client name, and ending with __ + assert!(capabilities + .get_client_for_tool("__cli__ent____tool") + .is_some()); + + // Test unicode in tool name, "client πŸš€" should become "client_" + assert!(capabilities.get_client_for_tool("client___tool").is_some()); + } + + #[tokio::test] + async fn test_dispatch_tool_call() { + // test that dispatch_tool_call parses out the sanitized name correctly, and extracts + // tool_names + let mock_model_config = + ModelConfig::new("test-model".to_string()).with_context_limit(200_000.into()); + + let mut capabilities = Capabilities::new(Box::new(MockProvider { + model_config: mock_model_config, + })); + + // Add some mock clients + capabilities.clients.insert( + normalize("test_client".to_string()), + Arc::new(Mutex::new(Box::new(MockClient {}))), + ); + + capabilities.clients.insert( + normalize("__cli__ent__".to_string()), + Arc::new(Mutex::new(Box::new(MockClient {}))), + ); + + capabilities.clients.insert( + normalize("client πŸš€".to_string()), + Arc::new(Mutex::new(Box::new(MockClient {}))), + ); + + // verify a normal tool call + let tool_call = ToolCall { + name: "test_client__tool".to_string(), + arguments: json!({}), + }; + + let result = capabilities.dispatch_tool_call(tool_call).await; + assert!(result.is_ok()); + + let tool_call = ToolCall { + name: "test_client__test__tool".to_string(), + arguments: json!({}), + }; + + let result = capabilities.dispatch_tool_call(tool_call).await; + assert!(result.is_ok()); + + // verify a multiple underscores dispatch + let tool_call = ToolCall { + name: "__cli__ent____tool".to_string(), + arguments: json!({}), + }; + + let result = capabilities.dispatch_tool_call(tool_call).await; + assert!(result.is_ok()); + + // Test unicode in tool name, "client πŸš€" should become "client_" + let tool_call = ToolCall { + name: "client___tool".to_string(), + arguments: json!({}), + }; + + let result = capabilities.dispatch_tool_call(tool_call).await; + assert!(result.is_ok()); + + let tool_call = ToolCall { + name: "client___test__tool".to_string(), + arguments: json!({}), + }; + + let result = capabilities.dispatch_tool_call(tool_call).await; + assert!(result.is_ok()); + + // this should error out, specifically for an ToolError::ExecutionError + let invalid_tool_call = ToolCall { + name: "client___tools".to_string(), + arguments: json!({}), + }; + + let result = capabilities.dispatch_tool_call(invalid_tool_call).await; + assert!(matches!( + result.err().unwrap(), + ToolError::ExecutionError(_) + )); + + // this should error out, specifically with an ToolError::NotFound + // this client doesn't exist + let invalid_tool_call = ToolCall { + name: "_client__tools".to_string(), + arguments: json!({}), + }; + + let result = capabilities.dispatch_tool_call(invalid_tool_call).await; + assert!(matches!(result.err().unwrap(), ToolError::NotFound(_))); + } +} diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs new file mode 100644 index 00000000..db5d5f3b --- /dev/null +++ b/crates/goose/src/agents/extension.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; + +use mcp_client::client::Error as ClientError; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Errors from Extension operation +#[derive(Error, Debug)] +pub enum ExtensionError { + #[error("Failed to start the MCP server from configuration `{0}` `{1}`")] + Initialization(ExtensionConfig, ClientError), + #[error("Failed a client call to an MCP server: {0}")] + Client(#[from] ClientError), + #[error("User Message exceeded context-limit. History could not be truncated to accomodate.")] + ContextLimit, + #[error("Transport error: {0}")] + Transport(#[from] mcp_client::transport::Error), +} + +pub type ExtensionResult = Result; + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct Envs { + /// A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host + #[serde(default)] + #[serde(flatten)] + map: HashMap, +} + +impl Envs { + pub fn new(map: HashMap) -> Self { + Self { map } + } + + pub fn get_env(&self) -> HashMap { + self.map + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } +} + +/// Represents the different types of MCP extensions that can be added to the manager +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type")] +pub enum ExtensionConfig { + /// Server-sent events client with a URI endpoint + #[serde(rename = "sse")] + Sse { + /// The name used to identify this extension + name: String, + uri: String, + #[serde(default)] + envs: Envs, + }, + /// Standard I/O client with command and arguments + #[serde(rename = "stdio")] + Stdio { + /// The name used to identify this extension + name: String, + cmd: String, + args: Vec, + #[serde(default)] + envs: Envs, + }, + /// Built-in extension that is part of the goose binary + #[serde(rename = "builtin")] + Builtin { + /// The name used to identify this extension + name: String, + }, +} + +impl Default for ExtensionConfig { + fn default() -> Self { + Self::Builtin { + name: String::from("default"), + } + } +} + +impl ExtensionConfig { + pub fn sse>(name: S, uri: S) -> Self { + Self::Sse { + name: name.into(), + uri: uri.into(), + envs: Envs::default(), + } + } + + pub fn stdio>(name: S, cmd: S) -> Self { + Self::Stdio { + name: name.into(), + cmd: cmd.into(), + args: vec![], + envs: Envs::default(), + } + } + + pub fn with_args(self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + match self { + Self::Stdio { + name, cmd, envs, .. + } => Self::Stdio { + name, + cmd, + envs, + args: args.into_iter().map(Into::into).collect(), + }, + other => other, + } + } + + /// Get the extension name regardless of variant + pub fn name(&self) -> &str { + match self { + Self::Sse { name, .. } => name, + Self::Stdio { name, .. } => name, + Self::Builtin { name } => name, + } + } +} + +impl std::fmt::Display for ExtensionConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExtensionConfig::Sse { name, uri, .. } => write!(f, "SSE({}: {})", name, uri), + ExtensionConfig::Stdio { + name, cmd, args, .. + } => { + write!(f, "Stdio({}: {} {})", name, cmd, args.join(" ")) + } + ExtensionConfig::Builtin { name } => write!(f, "Builtin({})", name), + } + } +} + +/// Information about the extension used for building prompts +#[derive(Clone, Debug, Serialize)] +pub struct ExtensionInfo { + name: String, + instructions: String, + has_resources: bool, +} + +impl ExtensionInfo { + pub fn new(name: &str, instructions: &str, has_resources: bool) -> Self { + Self { + name: name.to_string(), + instructions: instructions.to_string(), + has_resources, + } + } +} diff --git a/crates/goose/src/agents/factory.rs b/crates/goose/src/agents/factory.rs new file mode 100644 index 00000000..065adb1f --- /dev/null +++ b/crates/goose/src/agents/factory.rs @@ -0,0 +1,69 @@ +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +pub use super::Agent; +use crate::providers::base::Provider; + +type AgentConstructor = Box) -> Box + Send + Sync>; + +// Use std::sync::RwLock for interior mutability +static AGENT_REGISTRY: OnceLock>> = OnceLock::new(); + +/// Initialize the registry if it hasn't been initialized +fn registry() -> &'static RwLock> { + AGENT_REGISTRY.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Register a new agent version +pub fn register_agent( + version: &'static str, + constructor: impl Fn(Box) -> Box + Send + Sync + 'static, +) { + let registry = registry(); + if let Ok(mut map) = registry.write() { + map.insert(version, Box::new(constructor)); + } +} + +pub struct AgentFactory; + +impl AgentFactory { + /// Create a new agent instance of the specified version + pub fn create(version: &str, provider: Box) -> Option> { + let registry = registry(); + let map = registry + .read() + .expect("should be able to read the registry"); + let constructor = map.get(version)?; + Some(constructor(provider)) + } + + /// Get a list of all available agent versions + pub fn available_versions() -> Vec<&'static str> { + registry() + .read() + .map(|map| map.keys().copied().collect()) + .unwrap_or_default() + } + + /// Get the default version name + pub fn default_version() -> &'static str { + "truncate" + } +} + +/// Macro to help with agent registration +#[macro_export] +macro_rules! register_agent { + ($version:expr, $agent_type:ty) => { + paste::paste! { + #[ctor::ctor] + #[allow(non_snake_case)] + fn [<__register_agent_ $version>]() { + $crate::agents::factory::register_agent($version, |provider| { + Box::new(<$agent_type>::new(provider)) + }); + } + } + }; +} diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs new file mode 100644 index 00000000..370feaa8 --- /dev/null +++ b/crates/goose/src/agents/mod.rs @@ -0,0 +1,11 @@ +mod agent; +mod capabilities; +pub mod extension; +mod factory; +mod reference; +mod truncate; + +pub use agent::Agent; +pub use capabilities::Capabilities; +pub use extension::ExtensionConfig; +pub use factory::{register_agent, AgentFactory}; diff --git a/crates/goose/src/agents/reference.rs b/crates/goose/src/agents/reference.rs new file mode 100644 index 00000000..5d4cf86b --- /dev/null +++ b/crates/goose/src/agents/reference.rs @@ -0,0 +1,189 @@ +/// A simplified agent implementation used as a reference +/// It makes no attempt to handle context limits, and cannot read resources +use async_trait::async_trait; +use futures::stream::BoxStream; +use tokio::sync::Mutex; +use tracing::{debug, instrument}; + +use super::Agent; +use crate::agents::capabilities::Capabilities; +use crate::agents::extension::{ExtensionConfig, ExtensionResult}; +use crate::message::{Message, ToolRequest}; +use crate::providers::base::Provider; +use crate::providers::base::ProviderUsage; +use crate::register_agent; +use crate::token_counter::TokenCounter; +use indoc::indoc; +use mcp_core::tool::Tool; +use serde_json::{json, Value}; + +/// Reference implementation of an Agent +pub struct ReferenceAgent { + capabilities: Mutex, + _token_counter: TokenCounter, +} + +impl ReferenceAgent { + pub fn new(provider: Box) -> Self { + let token_counter = TokenCounter::new(provider.get_model_config().tokenizer_name()); + Self { + capabilities: Mutex::new(Capabilities::new(provider)), + _token_counter: token_counter, + } + } +} + +#[async_trait] +impl Agent for ReferenceAgent { + async fn add_extension(&mut self, extension: ExtensionConfig) -> ExtensionResult<()> { + let mut capabilities = self.capabilities.lock().await; + capabilities.add_extension(extension).await + } + + async fn remove_extension(&mut self, name: &str) { + let mut capabilities = self.capabilities.lock().await; + capabilities + .remove_extension(name) + .await + .expect("Failed to remove extension"); + } + + async fn list_extensions(&self) -> Vec { + let capabilities = self.capabilities.lock().await; + capabilities + .list_extensions() + .await + .expect("Failed to list extensions") + } + + async fn passthrough(&self, _extension: &str, _request: Value) -> ExtensionResult { + // TODO implement + Ok(Value::Null) + } + + #[instrument(skip(self, messages), fields(user_message))] + async fn reply( + &self, + messages: &[Message], + ) -> anyhow::Result>> { + let mut messages = messages.to_vec(); + let reply_span = tracing::Span::current(); + let mut capabilities = self.capabilities.lock().await; + let mut tools = capabilities.get_prefixed_tools().await?; + // we add in the read_resource tool by default + // TODO: make sure there is no collision with another extension's tool name + let read_resource_tool = Tool::new( + "platform__read_resource".to_string(), + indoc! {r#" + Read a resource from an extension. + + Resources allow extensions to share data that provide context to LLMs, such as + files, database schemas, or application-specific information. This tool searches for the + resource URI in the provided extension, and reads in the resource content. If no extension + is provided, the tool will search all extensions for the resource. + "#}.to_string(), + json!({ + "type": "object", + "required": ["uri"], + "properties": { + "uri": {"type": "string", "description": "Resource URI"}, + "extension_name": {"type": "string", "description": "Optional extension name"} + } + }), + ); + + let list_resources_tool = Tool::new( + "platform__list_resources".to_string(), + indoc! {r#" + List resources from an extension(s). + + Resources allow extensions to share data that provide context to LLMs, such as + files, database schemas, or application-specific information. This tool lists resources + in the provided extension, and returns a list for the user to browse. If no extension + is provided, the tool will search all extensions for the resource. + "#}.to_string(), + json!({ + "type": "object", + "properties": { + "extension_name": {"type": "string", "description": "Optional extension name"} + } + }), + ); + + if capabilities.supports_resources() { + tools.push(read_resource_tool); + tools.push(list_resources_tool); + } + + let system_prompt = capabilities.get_system_prompt().await; + + // Set the user_message field in the span instead of creating a new event + if let Some(content) = messages + .last() + .and_then(|msg| msg.content.first()) + .and_then(|c| c.as_text()) + { + debug!("user_message" = &content); + } + + Ok(Box::pin(async_stream::try_stream! { + let _reply_guard = reply_span.enter(); + loop { + // Get completion from provider + let (response, usage) = capabilities.provider().complete( + &system_prompt, + &messages, + &tools, + ).await?; + capabilities.record_usage(usage).await; + + // Yield the assistant's response + yield response.clone(); + + tokio::task::yield_now().await; + + // First collect any tool requests + let tool_requests: Vec<&ToolRequest> = response.content + .iter() + .filter_map(|content| content.as_tool_request()) + .collect(); + + if tool_requests.is_empty() { + break; + } + + // Then dispatch each in parallel + let futures: Vec<_> = tool_requests + .iter() + .filter_map(|request| request.tool_call.clone().ok()) + .map(|tool_call| capabilities.dispatch_tool_call(tool_call)) + .collect(); + + // Process all the futures in parallel but wait until all are finished + let outputs = futures::future::join_all(futures).await; + + // Create a message with the responses + let mut message_tool_response = Message::user(); + // Now combine these into MessageContent::ToolResponse using the original ID + for (request, output) in tool_requests.iter().zip(outputs.into_iter()) { + message_tool_response = message_tool_response.with_tool_response( + request.id.clone(), + output, + ); + } + + yield message_tool_response.clone(); + + messages.push(response); + messages.push(message_tool_response); + } + })) + } + + async fn usage(&self) -> Vec { + let capabilities = self.capabilities.lock().await; + capabilities.get_usage().await + } +} + +register_agent!("reference", ReferenceAgent); diff --git a/crates/goose/src/agents/truncate.rs b/crates/goose/src/agents/truncate.rs new file mode 100644 index 00000000..cef34c60 --- /dev/null +++ b/crates/goose/src/agents/truncate.rs @@ -0,0 +1,274 @@ +/// A truncate agent that truncates the conversation history when it exceeds the model's context limit +/// It makes no attempt to handle context limits, and cannot read resources +use async_trait::async_trait; +use futures::stream::BoxStream; +use tokio::sync::Mutex; +use tracing::{debug, error, instrument, warn}; + +use super::Agent; +use crate::agents::capabilities::Capabilities; +use crate::agents::extension::{ExtensionConfig, ExtensionResult}; +use crate::message::{Message, ToolRequest}; +use crate::providers::base::Provider; +use crate::providers::base::ProviderUsage; +use crate::providers::errors::ProviderError; +use crate::register_agent; +use crate::token_counter::TokenCounter; +use crate::truncate::{truncate_messages, OldestFirstTruncation}; +use indoc::indoc; +use mcp_core::tool::Tool; +use serde_json::{json, Value}; + +const MAX_TRUNCATION_ATTEMPTS: usize = 3; +const ESTIMATE_FACTOR_DECAY: f32 = 0.9; + +/// Truncate implementation of an Agent +pub struct TruncateAgent { + capabilities: Mutex, + token_counter: TokenCounter, +} + +impl TruncateAgent { + pub fn new(provider: Box) -> Self { + let token_counter = TokenCounter::new(provider.get_model_config().tokenizer_name()); + Self { + capabilities: Mutex::new(Capabilities::new(provider)), + token_counter, + } + } + + /// Truncates the messages to fit within the model's context window + /// Ensures the last message is a user message and removes tool call-response pairs + async fn truncate_messages( + &self, + messages: &mut Vec, + estimate_factor: f32, + ) -> anyhow::Result<()> { + // Model's actual context limit + let context_limit = self + .capabilities + .lock() + .await + .provider() + .get_model_config() + .context_limit(); + + // Our conservative estimate of the **target** context limit + // Our token count is an estimate since model providers often don't provide the tokenizer (eg. Claude) + let context_limit = (context_limit as f32 * estimate_factor) as usize; + + // Calculate current token count + let mut token_counts: Vec = messages + .iter() + .map(|msg| self.token_counter.count_tokens(&msg.as_concat_text())) + .collect(); + + let _ = truncate_messages( + messages, + &mut token_counts, + context_limit, + &OldestFirstTruncation, + ); + + Ok(()) + } +} + +#[async_trait] +impl Agent for TruncateAgent { + async fn add_extension(&mut self, extension: ExtensionConfig) -> ExtensionResult<()> { + let mut capabilities = self.capabilities.lock().await; + capabilities.add_extension(extension).await + } + + async fn remove_extension(&mut self, name: &str) { + let mut capabilities = self.capabilities.lock().await; + capabilities + .remove_extension(name) + .await + .expect("Failed to remove extension"); + } + + async fn list_extensions(&self) -> Vec { + let capabilities = self.capabilities.lock().await; + capabilities + .list_extensions() + .await + .expect("Failed to list extensions") + } + + async fn passthrough(&self, _extension: &str, _request: Value) -> ExtensionResult { + // TODO implement + Ok(Value::Null) + } + + #[instrument(skip(self, messages), fields(user_message))] + async fn reply( + &self, + messages: &[Message], + ) -> anyhow::Result>> { + let mut messages = messages.to_vec(); + let reply_span = tracing::Span::current(); + let mut capabilities = self.capabilities.lock().await; + let mut tools = capabilities.get_prefixed_tools().await?; + let mut truncation_attempt: usize = 0; + + // we add in the read_resource tool by default + // TODO: make sure there is no collision with another extension's tool name + let read_resource_tool = Tool::new( + "platform__read_resource".to_string(), + indoc! {r#" + Read a resource from an extension. + + Resources allow extensions to share data that provide context to LLMs, such as + files, database schemas, or application-specific information. This tool searches for the + resource URI in the provided extension, and reads in the resource content. If no extension + is provided, the tool will search all extensions for the resource. + "#}.to_string(), + json!({ + "type": "object", + "required": ["uri"], + "properties": { + "uri": {"type": "string", "description": "Resource URI"}, + "extension_name": {"type": "string", "description": "Optional extension name"} + } + }), + ); + + let list_resources_tool = Tool::new( + "platform__list_resources".to_string(), + indoc! {r#" + List resources from an extension(s). + + Resources allow extensions to share data that provide context to LLMs, such as + files, database schemas, or application-specific information. This tool lists resources + in the provided extension, and returns a list for the user to browse. If no extension + is provided, the tool will search all extensions for the resource. + "#}.to_string(), + json!({ + "type": "object", + "properties": { + "extension_name": {"type": "string", "description": "Optional extension name"} + } + }), + ); + + if capabilities.supports_resources() { + tools.push(read_resource_tool); + tools.push(list_resources_tool); + } + + let system_prompt = capabilities.get_system_prompt().await; + + // Set the user_message field in the span instead of creating a new event + if let Some(content) = messages + .last() + .and_then(|msg| msg.content.first()) + .and_then(|c| c.as_text()) + { + debug!("user_message" = &content); + } + + Ok(Box::pin(async_stream::try_stream! { + let _reply_guard = reply_span.enter(); + loop { + // Attempt to get completion from provider + match capabilities.provider().complete( + &system_prompt, + &messages, + &tools, + ).await { + Ok((response, usage)) => { + capabilities.record_usage(usage).await; + + // Reset truncation attempt + truncation_attempt = 0; + + // Yield the assistant's response + yield response.clone(); + + tokio::task::yield_now().await; + + // First collect any tool requests + let tool_requests: Vec<&ToolRequest> = response.content + .iter() + .filter_map(|content| content.as_tool_request()) + .collect(); + + if tool_requests.is_empty() { + break; + } + + // Then dispatch each in parallel + let futures: Vec<_> = tool_requests + .iter() + .filter_map(|request| request.tool_call.clone().ok()) + .map(|tool_call| capabilities.dispatch_tool_call(tool_call)) + .collect(); + + // Process all the futures in parallel but wait until all are finished + let outputs = futures::future::join_all(futures).await; + + // Create a message with the responses + let mut message_tool_response = Message::user(); + // Now combine these into MessageContent::ToolResponse using the original ID + for (request, output) in tool_requests.iter().zip(outputs.into_iter()) { + message_tool_response = message_tool_response.with_tool_response( + request.id.clone(), + output, + ); + } + + yield message_tool_response.clone(); + + messages.push(response); + messages.push(message_tool_response); + }, + Err(ProviderError::ContextLengthExceeded(_)) => { + if truncation_attempt >= MAX_TRUNCATION_ATTEMPTS { + // Create an error message & terminate the stream + // the previous message would have been a user message (e.g. before any tool calls, this is just after the input message. + // at the start of a loop after a tool call, it would be after a tool_use assistant followed by a tool_result user) + yield Message::assistant().with_text("Error: Context length exceeds limits even after multiple attempts to truncate."); + break; + } + + truncation_attempt += 1; + warn!("Context length exceeded. Truncation Attempt: {}/{}.", truncation_attempt, MAX_TRUNCATION_ATTEMPTS); + + // Decay the estimate factor as we make more truncation attempts + // Estimate factor decays like this over time: 0.9, 0.81, 0.729, ... + let estimate_factor: f32 = ESTIMATE_FACTOR_DECAY.powi(truncation_attempt as i32); + + // release the lock before truncation to prevent deadlock + drop(capabilities); + + self.truncate_messages(&mut messages, estimate_factor).await?; + + // Re-acquire the lock + capabilities = self.capabilities.lock().await; + + // Retry the loop after truncation + continue; + }, + Err(e) => { + // Create an error message & terminate the stream + error!("Error: {}", e); + yield Message::assistant().with_text(format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error.")); + break; + } + } + + // Yield control back to the scheduler to prevent blocking + tokio::task::yield_now().await; + } + })) + } + + async fn usage(&self) -> Vec { + let capabilities = self.capabilities.lock().await; + capabilities.get_usage().await + } +} + +register_agent!("truncate", TruncateAgent); diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs new file mode 100644 index 00000000..15150bfa --- /dev/null +++ b/crates/goose/src/config/base.rs @@ -0,0 +1,467 @@ +use keyring::Entry; +use once_cell::sync::OnceCell; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; +use std::env; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +const KEYRING_SERVICE: &str = "goose"; +const KEYRING_USERNAME: &str = "secrets"; + +#[cfg(test)] +const TEST_KEYRING_SERVICE: &str = "goose-test"; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Configuration value not found: {0}")] + NotFound(String), + #[error("Failed to deserialize value: {0}")] + DeserializeError(String), + #[error("Failed to read config file: {0}")] + FileError(#[from] std::io::Error), + #[error("Failed to create config directory: {0}")] + DirectoryError(String), + #[error("Failed to access keyring: {0}")] + KeyringError(String), +} + +impl From for ConfigError { + fn from(err: serde_json::Error) -> Self { + ConfigError::DeserializeError(err.to_string()) + } +} + +impl From for ConfigError { + fn from(err: serde_yaml::Error) -> Self { + ConfigError::DeserializeError(err.to_string()) + } +} + +impl From for ConfigError { + fn from(err: keyring::Error) -> Self { + ConfigError::KeyringError(err.to_string()) + } +} + +/// Configuration management for Goose. +/// +/// This module provides a flexible configuration system that supports: +/// - Dynamic configuration keys +/// - Multiple value types through serde deserialization +/// - Environment variable overrides +/// - YAML-based configuration file storage +/// - Hot reloading of configuration changes +/// - Secure secret storage in system keyring +/// +/// Configuration values are loaded with the following precedence: +/// 1. Environment variables (exact key match) +/// 2. Configuration file (~/.config/goose/config.yaml by default) +/// +/// Secrets are loaded with the following precedence: +/// 1. Environment variables (exact key match) +/// 2. System keyring +/// +/// # Examples +/// +/// ```no_run +/// use goose::config::Config; +/// use serde::Deserialize; +/// +/// // Get a string value +/// let config = Config::global(); +/// let api_key: String = config.get("OPENAI_API_KEY").unwrap(); +/// +/// // Get a complex type +/// #[derive(Deserialize)] +/// struct ServerConfig { +/// host: String, +/// port: u16, +/// } +/// +/// let server_config: ServerConfig = config.get("server").unwrap(); +/// ``` +/// +/// # Naming Convention +/// we recommend snake_case for keys, and will convert to UPPERCASE when +/// checking for environment overrides. e.g. openai_api_key will check for an +/// environment variable OPENAI_API_KEY +/// +/// For Goose-specific configuration, consider prefixing with "goose_" to avoid conflicts. +pub struct Config { + config_path: PathBuf, + keyring_service: String, +} + +// Global instance +static GLOBAL_CONFIG: OnceCell = OnceCell::new(); + +impl Default for Config { + fn default() -> Self { + let config_dir = dirs::home_dir() + .expect("goose requires a home dir") + .join(".config") + .join("goose"); + std::fs::create_dir_all(&config_dir).expect("Failed to create config directory"); + + let config_path = config_dir.join("config.yaml"); + Config { + config_path, + keyring_service: KEYRING_SERVICE.to_string(), + } + } +} + +impl Config { + /// Get the global configuration instance. + /// + /// This will initialize the configuration with the default path (~/.config/goose/config.yaml) + /// if it hasn't been initialized yet. + pub fn global() -> &'static Config { + GLOBAL_CONFIG.get_or_init(Config::default) + } + + /// Create a new configuration instance with custom paths + /// + /// This is primarily useful for testing or for applications that need + /// to manage multiple configuration files. + pub fn new>(config_path: P, service: &str) -> Result { + Ok(Config { + config_path: config_path.as_ref().to_path_buf(), + keyring_service: service.to_string(), + }) + } + + /// Check if this config already exists + pub fn exists(&self) -> bool { + self.config_path.exists() + } + + /// Check if this config already exists + pub fn clear(&self) -> Result<(), ConfigError> { + Ok(std::fs::remove_file(&self.config_path)?) + } + + /// Get the path to the configuration file + pub fn path(&self) -> String { + self.config_path.to_string_lossy().to_string() + } + + // Load current values from the config file + fn load_values(&self) -> Result, ConfigError> { + if self.config_path.exists() { + let file_content = std::fs::read_to_string(&self.config_path)?; + // Parse YAML into JSON Value for consistent internal representation + let yaml_value: serde_yaml::Value = serde_yaml::from_str(&file_content)?; + let json_value: Value = serde_json::to_value(yaml_value)?; + + match json_value { + Value::Object(map) => Ok(map.into_iter().collect()), + _ => Ok(HashMap::new()), + } + } else { + Ok(HashMap::new()) + } + } + + // Load current secrets from the keyring + fn load_secrets(&self) -> Result, ConfigError> { + let entry = Entry::new(&self.keyring_service, KEYRING_USERNAME)?; + + match entry.get_password() { + Ok(content) => { + let values: HashMap = serde_json::from_str(&content)?; + Ok(values) + } + Err(keyring::Error::NoEntry) => Ok(HashMap::new()), + Err(e) => Err(ConfigError::KeyringError(e.to_string())), + } + } + + /// Get a configuration value. + /// + /// This will attempt to get the value from: + /// 1. Environment variable with the exact key name + /// 2. Configuration file + /// + /// The value will be deserialized into the requested type. This works with + /// both simple types (String, i32, etc.) and complex types that implement + /// serde::Deserialize. + /// + /// # Errors + /// + /// Returns a ConfigError if: + /// - The key doesn't exist in either environment or config file + /// - The value cannot be deserialized into the requested type + /// - There is an error reading the config file + pub fn get Deserialize<'de>>(&self, key: &str) -> Result { + // First check environment variables (convert to uppercase) + let env_key = key.to_uppercase(); + if let Ok(val) = env::var(&env_key) { + // Parse the environment variable value into a serde_json::Value + let value: Value = serde_json::from_str(&val).unwrap_or(Value::String(val)); + return Ok(serde_json::from_value(value)?); + } + + // Load current values from file + let values = self.load_values()?; + + // Then check our stored values + values + .get(key) + .ok_or_else(|| ConfigError::NotFound(key.to_string())) + .and_then(|v| Ok(serde_json::from_value(v.clone())?)) + } + + /// Set a configuration value in the config file. + /// + /// This will immediately write the value to the config file. The value + /// can be any type that can be serialized to JSON/YAML. + /// + /// Note that this does not affect environment variables - those can only + /// be set through the system environment. + /// + /// # Errors + /// + /// Returns a ConfigError if: + /// - There is an error reading or writing the config file + /// - There is an error serializing the value + pub fn set(&self, key: &str, value: Value) -> Result<(), ConfigError> { + let mut values = self.load_values()?; + values.insert(key.to_string(), value); + + // Convert to YAML for storage + let yaml_value = serde_yaml::to_string(&values)?; + + // Ensure the directory exists + if let Some(parent) = self.config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| ConfigError::DirectoryError(e.to_string()))?; + } + + std::fs::write(&self.config_path, yaml_value)?; + Ok(()) + } + + /// Get a secret value. + /// + /// This will attempt to get the value from: + /// 1. Environment variable with the exact key name + /// 2. System keyring + /// + /// The value will be deserialized into the requested type. This works with + /// both simple types (String, i32, etc.) and complex types that implement + /// serde::Deserialize. + /// + /// # Errors + /// + /// Returns a ConfigError if: + /// - The key doesn't exist in either environment or keyring + /// - The value cannot be deserialized into the requested type + /// - There is an error accessing the keyring + pub fn get_secret Deserialize<'de>>(&self, key: &str) -> Result { + // First check environment variables (convert to uppercase) + let env_key = key.to_uppercase(); + if let Ok(val) = env::var(&env_key) { + let value: Value = serde_json::from_str(&val).unwrap_or(Value::String(val)); + return Ok(serde_json::from_value(value)?); + } + + // Then check keyring + let values = self.load_secrets()?; + values + .get(key) + .ok_or_else(|| ConfigError::NotFound(key.to_string())) + .and_then(|v| Ok(serde_json::from_value(v.clone())?)) + } + + /// Set a secret value in the system keyring. + /// + /// This will store the value in a single JSON object in the system keyring, + /// alongside any other secrets. The value can be any type that can be + /// serialized to JSON. + /// + /// Note that this does not affect environment variables - those can only + /// be set through the system environment. + /// + /// # Errors + /// + /// Returns a ConfigError if: + /// - There is an error accessing the keyring + /// - There is an error serializing the value + pub fn set_secret(&self, key: &str, value: Value) -> Result<(), ConfigError> { + let mut values = self.load_secrets()?; + values.insert(key.to_string(), value); + + let json_value = serde_json::to_string(&values)?; + let entry = Entry::new(&self.keyring_service, KEYRING_USERNAME)?; + entry.set_password(&json_value)?; + Ok(()) + } + + /// Delete a secret from the system keyring. + /// + /// This will remove the specified key from the JSON object in the system keyring. + /// Other secrets will remain unchanged. + /// + /// # Errors + /// + /// Returns a ConfigError if: + /// - There is an error accessing the keyring + /// - There is an error serializing the remaining values + pub fn delete_secret(&self, key: &str) -> Result<(), ConfigError> { + let mut values = self.load_secrets()?; + values.remove(key); + + let json_value = serde_json::to_string(&values)?; + let entry = Entry::new(&self.keyring_service, KEYRING_USERNAME)?; + entry.set_password(&json_value)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use tempfile::NamedTempFile; + + fn cleanup_keyring() -> Result<(), ConfigError> { + let entry = Entry::new(TEST_KEYRING_SERVICE, KEYRING_USERNAME)?; + match entry.delete_credential() { + Ok(_) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(ConfigError::KeyringError(e.to_string())), + } + } + + #[test] + fn test_basic_config() -> Result<(), ConfigError> { + let temp_file = NamedTempFile::new().unwrap(); + let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?; + + // Set a simple string value + config.set("test_key", Value::String("test_value".to_string()))?; + + // Test simple string retrieval + let value: String = config.get("test_key")?; + assert_eq!(value, "test_value"); + + // Test with environment variable override + std::env::set_var("TEST_KEY", "env_value"); + let value: String = config.get("test_key")?; + assert_eq!(value, "env_value"); + + Ok(()) + } + + #[test] + fn test_complex_type() -> Result<(), ConfigError> { + #[derive(Deserialize, Debug, PartialEq)] + struct TestStruct { + field1: String, + field2: i32, + } + + let temp_file = NamedTempFile::new().unwrap(); + let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?; + + // Set a complex value + config.set( + "complex_key", + serde_json::json!({ + "field1": "hello", + "field2": 42 + }), + )?; + + let value: TestStruct = config.get("complex_key")?; + assert_eq!(value.field1, "hello"); + assert_eq!(value.field2, 42); + + Ok(()) + } + + #[test] + fn test_missing_value() { + let temp_file = NamedTempFile::new().unwrap(); + let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE).unwrap(); + + let result: Result = config.get("nonexistent_key"); + assert!(matches!(result, Err(ConfigError::NotFound(_)))); + } + + #[test] + fn test_yaml_formatting() -> Result<(), ConfigError> { + let temp_file = NamedTempFile::new().unwrap(); + let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?; + + config.set("key1", Value::String("value1".to_string()))?; + config.set("key2", Value::Number(42.into()))?; + + // Read the file directly to check YAML formatting + let content = std::fs::read_to_string(temp_file.path())?; + assert!(content.contains("key1: value1")); + assert!(content.contains("key2: 42")); + + Ok(()) + } + + #[test] + #[serial] + fn test_secret_management() -> Result<(), ConfigError> { + cleanup_keyring()?; + let temp_file = NamedTempFile::new().unwrap(); + let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?; + + // Test setting and getting a simple secret + config.set_secret("api_key", Value::String("secret123".to_string()))?; + let value: String = config.get_secret("api_key")?; + assert_eq!(value, "secret123"); + + // Test environment variable override + std::env::set_var("API_KEY", "env_secret"); + let value: String = config.get_secret("api_key")?; + assert_eq!(value, "env_secret"); + std::env::remove_var("API_KEY"); + + // Test deleting a secret + config.delete_secret("api_key")?; + let result: Result = config.get_secret("api_key"); + assert!(matches!(result, Err(ConfigError::NotFound(_)))); + + cleanup_keyring()?; + Ok(()) + } + + #[test] + #[serial] + fn test_multiple_secrets() -> Result<(), ConfigError> { + cleanup_keyring()?; + let temp_file = NamedTempFile::new().unwrap(); + let config = Config::new(temp_file.path(), TEST_KEYRING_SERVICE)?; + + // Set multiple secrets + config.set_secret("key1", Value::String("secret1".to_string()))?; + config.set_secret("key2", Value::String("secret2".to_string()))?; + + // Verify both exist + let value1: String = config.get_secret("key1")?; + let value2: String = config.get_secret("key2")?; + assert_eq!(value1, "secret1"); + assert_eq!(value2, "secret2"); + + // Delete one secret + config.delete_secret("key1")?; + + // Verify key1 is gone but key2 remains + let result1: Result = config.get_secret("key1"); + let value2: String = config.get_secret("key2")?; + assert!(matches!(result1, Err(ConfigError::NotFound(_)))); + assert_eq!(value2, "secret2"); + + cleanup_keyring()?; + Ok(()) + } +} diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs new file mode 100644 index 00000000..ffbc955a --- /dev/null +++ b/crates/goose/src/config/extensions.rs @@ -0,0 +1,118 @@ +use super::base::Config; +use crate::agents::ExtensionConfig; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +const DEFAULT_EXTENSION: &str = "developer"; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ExtensionEntry { + pub enabled: bool, + #[serde(flatten)] + pub config: ExtensionConfig, +} + +/// Extension configuration management +pub struct ExtensionManager; + +impl ExtensionManager { + /// Get the extension configuration if enabled + pub fn get_config(name: &str) -> Result> { + let config = Config::global(); + + // Try to get the extension entry + let extensions: HashMap = match config.get("extensions") { + Ok(exts) => exts, + Err(super::ConfigError::NotFound(_)) => { + // Initialize with default developer extension + let defaults = HashMap::from([( + DEFAULT_EXTENSION.to_string(), + ExtensionEntry { + enabled: true, + config: ExtensionConfig::Builtin { + name: DEFAULT_EXTENSION.to_string(), + }, + }, + )]); + config.set("extensions", serde_json::to_value(&defaults)?)?; + defaults + } + Err(e) => return Err(e.into()), + }; + + Ok(extensions.get(name).and_then(|entry| { + if entry.enabled { + Some(entry.config.clone()) + } else { + None + } + })) + } + + /// Set or update an extension configuration + pub fn set(entry: ExtensionEntry) -> Result<()> { + let config = Config::global(); + + let mut extensions: HashMap = + config.get("extensions").unwrap_or_else(|_| HashMap::new()); + + extensions.insert(entry.config.name().parse()?, entry); + config.set("extensions", serde_json::to_value(extensions)?)?; + Ok(()) + } + + /// Remove an extension configuration + pub fn remove(name: &str) -> Result<()> { + let config = Config::global(); + + let mut extensions: HashMap = + config.get("extensions").unwrap_or_else(|_| HashMap::new()); + + extensions.remove(name); + config.set("extensions", serde_json::to_value(extensions)?)?; + Ok(()) + } + + /// Enable or disable an extension + pub fn set_enabled(name: &str, enabled: bool) -> Result<()> { + let config = Config::global(); + + let mut extensions: HashMap = + config.get("extensions").unwrap_or_else(|_| HashMap::new()); + + if let Some(entry) = extensions.get_mut(name) { + entry.enabled = enabled; + config.set("extensions", serde_json::to_value(extensions)?)?; + } + Ok(()) + } + + /// Get all extensions and their configurations + pub fn get_all() -> Result> { + let config = Config::global(); + let extensions: HashMap = + config.get("extensions").unwrap_or_default(); + Ok(Vec::from_iter(extensions.values().cloned())) + } + + /// Get all extension names + pub fn get_all_names() -> Result> { + let config = Config::global(); + Ok(config + .get("extensions") + .unwrap_or_else(|_| get_keys(Default::default()))) + } + + /// Check if an extension is enabled + pub fn is_enabled(name: &str) -> Result { + let config = Config::global(); + let extensions: HashMap = + config.get("extensions").unwrap_or_else(|_| HashMap::new()); + + Ok(extensions.get(name).map(|e| e.enabled).unwrap_or(false)) + } +} +fn get_keys(entries: HashMap) -> Vec { + entries.into_keys().collect() +} diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs new file mode 100644 index 00000000..fdaa8a86 --- /dev/null +++ b/crates/goose/src/config/mod.rs @@ -0,0 +1,6 @@ +mod base; +mod extensions; + +pub use crate::agents::ExtensionConfig; +pub use base::{Config, ConfigError}; +pub use extensions::{ExtensionEntry, ExtensionManager}; diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs new file mode 100644 index 00000000..83ebc320 --- /dev/null +++ b/crates/goose/src/lib.rs @@ -0,0 +1,9 @@ +pub mod agents; +pub mod config; +pub mod message; +pub mod model; +pub mod prompt_template; +pub mod providers; +pub mod token_counter; +pub mod tracing; +pub mod truncate; diff --git a/crates/goose/src/message.rs b/crates/goose/src/message.rs new file mode 100644 index 00000000..30de253f --- /dev/null +++ b/crates/goose/src/message.rs @@ -0,0 +1,250 @@ +use std::collections::HashSet; + +/// Messages which represent the content sent back and forth to LLM provider +/// +/// We use these messages in the agent code, and interfaces which interact with +/// the agent. That let's us reuse message histories across different interfaces. +/// +/// The content of the messages uses MCP types to avoid additional conversions +/// when interacting with MCP servers. +use chrono::Utc; +use mcp_core::content::{Content, ImageContent, TextContent}; +use mcp_core::handler::ToolResult; +use mcp_core::role::Role; +use mcp_core::tool::ToolCall; + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ToolRequest { + pub id: String, + pub tool_call: ToolResult, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ToolResponse { + pub id: String, + pub tool_result: ToolResult>, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +/// Content passed inside a message, which can be both simple content and tool content +pub enum MessageContent { + Text(TextContent), + Image(ImageContent), + ToolRequest(ToolRequest), + ToolResponse(ToolResponse), +} + +impl MessageContent { + pub fn text>(text: S) -> Self { + MessageContent::Text(TextContent { + text: text.into(), + annotations: None, + }) + } + + pub fn image, T: Into>(data: S, mime_type: T) -> Self { + MessageContent::Image(ImageContent { + data: data.into(), + mime_type: mime_type.into(), + annotations: None, + }) + } + + pub fn tool_request>(id: S, tool_call: ToolResult) -> Self { + MessageContent::ToolRequest(ToolRequest { + id: id.into(), + tool_call, + }) + } + + pub fn tool_response>(id: S, tool_result: ToolResult>) -> Self { + MessageContent::ToolResponse(ToolResponse { + id: id.into(), + tool_result, + }) + } + + pub fn as_tool_request(&self) -> Option<&ToolRequest> { + if let MessageContent::ToolRequest(ref tool_request) = self { + Some(tool_request) + } else { + None + } + } + + pub fn as_tool_response(&self) -> Option<&ToolResponse> { + if let MessageContent::ToolResponse(ref tool_response) = self { + Some(tool_response) + } else { + None + } + } + + pub fn as_tool_response_text(&self) -> Option { + if let Some(tool_response) = self.as_tool_response() { + if let Ok(contents) = &tool_response.tool_result { + let texts: Vec = contents + .iter() + .filter_map(|content| content.as_text().map(String::from)) + .collect(); + if !texts.is_empty() { + return Some(texts.join("\n")); + } + } + } + None + } + + /// Get the text content if this is a TextContent variant + pub fn as_text(&self) -> Option<&str> { + match self { + MessageContent::Text(text) => Some(&text.text), + _ => None, + } + } +} + +impl From for MessageContent { + fn from(content: Content) -> Self { + match content { + Content::Text(text) => MessageContent::Text(text), + Content::Image(image) => MessageContent::Image(image), + Content::Resource(resource) => MessageContent::Text(TextContent { + text: resource.get_text(), + annotations: None, + }), + } + } +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +/// A message to or from an LLM +pub struct Message { + pub role: Role, + pub created: i64, + pub content: Vec, +} + +impl Message { + /// Create a new user message with the current timestamp + pub fn user() -> Self { + Message { + role: Role::User, + created: Utc::now().timestamp(), + content: Vec::new(), + } + } + + /// Create a new assistant message with the current timestamp + pub fn assistant() -> Self { + Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: Vec::new(), + } + } + + /// Add any MessageContent to the message + pub fn with_content(mut self, content: MessageContent) -> Self { + self.content.push(content); + self + } + + /// Add text content to the message + pub fn with_text>(self, text: S) -> Self { + self.with_content(MessageContent::text(text)) + } + + /// Add image content to the message + pub fn with_image, T: Into>(self, data: S, mime_type: T) -> Self { + self.with_content(MessageContent::image(data, mime_type)) + } + + /// Add a tool request to the message + pub fn with_tool_request>( + self, + id: S, + tool_call: ToolResult, + ) -> Self { + self.with_content(MessageContent::tool_request(id, tool_call)) + } + + /// Add a tool response to the message + pub fn with_tool_response>( + self, + id: S, + result: ToolResult>, + ) -> Self { + self.with_content(MessageContent::tool_response(id, result)) + } + + /// Get the concatenated text content of the message, separated by newlines + pub fn as_concat_text(&self) -> String { + self.content + .iter() + .filter_map(|c| c.as_text()) + .collect::>() + .join("\n") + } + + /// Check if the message is a tool call + pub fn is_tool_call(&self) -> bool { + self.content + .iter() + .any(|c| matches!(c, MessageContent::ToolRequest(_))) + } + + /// Check if the message is a tool response + pub fn is_tool_response(&self) -> bool { + self.content + .iter() + .any(|c| matches!(c, MessageContent::ToolResponse(_))) + } + + /// Retrieves all tool `id` from the message + pub fn get_tool_ids(&self) -> HashSet<&str> { + self.content + .iter() + .filter_map(|content| match content { + MessageContent::ToolRequest(req) => Some(req.id.as_str()), + MessageContent::ToolResponse(res) => Some(res.id.as_str()), + _ => None, + }) + .collect() + } + + /// Retrieves all tool `id` from ToolRequest messages + pub fn get_tool_request_ids(&self) -> HashSet<&str> { + self.content + .iter() + .filter_map(|content| { + if let MessageContent::ToolRequest(req) = content { + Some(req.id.as_str()) + } else { + None + } + }) + .collect() + } + + /// Retrieves all tool `id` from ToolResponse messages + pub fn get_tool_response_ids(&self) -> HashSet<&str> { + self.content + .iter() + .filter_map(|content| { + if let MessageContent::ToolResponse(res) = content { + Some(res.id.as_str()) + } else { + None + } + }) + .collect() + } + + /// Check if the message has only TextContent + pub fn has_only_text_content(&self) -> bool { + self.content + .iter() + .all(|c| matches!(c, MessageContent::Text(_))) + } +} diff --git a/crates/goose/src/model.rs b/crates/goose/src/model.rs new file mode 100644 index 00000000..a35d2223 --- /dev/null +++ b/crates/goose/src/model.rs @@ -0,0 +1,142 @@ +use serde::{Deserialize, Serialize}; + +const DEFAULT_CONTEXT_LIMIT: usize = 128_000; + +// Tokenizer names, used to infer from model name +pub const GPT_4O_TOKENIZER: &str = "Xenova--gpt-4o"; +pub const CLAUDE_TOKENIZER: &str = "Xenova--claude-tokenizer"; + +/// Configuration for model-specific settings and limits +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelConfig { + /// The name of the model to use + pub model_name: String, + // Optional tokenizer name (corresponds to the sanitized HuggingFace tokenizer name) + // "Xenova/gpt-4o" -> "Xenova/gpt-4o" + // If not provided, best attempt will be made to infer from model name or default + pub tokenizer_name: String, + /// Optional explicit context limit that overrides any defaults + pub context_limit: Option, + /// Optional temperature setting (0.0 - 1.0) + pub temperature: Option, + /// Optional maximum tokens to generate + pub max_tokens: Option, +} + +impl ModelConfig { + /// Create a new ModelConfig with the specified model name + /// + /// The context limit is set with the following precedence: + /// 1. Explicit context_limit if provided in config + /// 2. Model-specific default based on model name + /// 3. Global default (128_000) (in get_context_limit) + pub fn new(model_name: String) -> Self { + let context_limit = Self::get_model_specific_limit(&model_name); + let tokenizer_name = Self::infer_tokenizer_name(&model_name); + + Self { + model_name, + tokenizer_name: tokenizer_name.to_string(), + context_limit, + temperature: None, + max_tokens: None, + } + } + + fn infer_tokenizer_name(model_name: &str) -> &'static str { + if model_name.contains("claude") { + CLAUDE_TOKENIZER + } else { + // Default tokenizer + GPT_4O_TOKENIZER + } + } + + /// Get model-specific context limit based on model name + fn get_model_specific_limit(model_name: &str) -> Option { + // Implement some sensible defaults + match model_name { + // OpenAI models, https://platform.openai.com/docs/models#models-overview + name if name.contains("gpt-4o") => Some(128_000), + name if name.contains("gpt-4-turbo") => Some(128_000), + + // Anthropic models, https://docs.anthropic.com/en/docs/about-claude/models + name if name.contains("claude-3") => Some(200_000), + + // Meta Llama models, https://github.com/meta-llama/llama-models/tree/main?tab=readme-ov-file#llama-models-1 + name if name.contains("llama3.2") => Some(128_000), + name if name.contains("llama3.3") => Some(128_000), + _ => None, + } + } + + /// Set an explicit context limit + pub fn with_context_limit(mut self, limit: Option) -> Self { + // Default is None and therefore DEFAULT_CONTEXT_LIMIT, only set + // if input is Some to allow passing through with_context_limit in + // configuration cases + if limit.is_some() { + self.context_limit = limit; + } + self + } + + /// Set the temperature + pub fn with_temperature(mut self, temp: Option) -> Self { + self.temperature = temp; + self + } + + /// Set the max tokens + pub fn with_max_tokens(mut self, tokens: Option) -> Self { + self.max_tokens = tokens; + self + } + + // Get the tokenizer name + pub fn tokenizer_name(&self) -> &str { + &self.tokenizer_name + } + + /// Get the context_limit for the current model + /// If none are defined, use the DEFAULT_CONTEXT_LIMIT + pub fn context_limit(&self) -> usize { + self.context_limit.unwrap_or(DEFAULT_CONTEXT_LIMIT) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_model_config_context_limits() { + // Test explicit limit + let config = + ModelConfig::new("claude-3-opus".to_string()).with_context_limit(Some(150_000)); + assert_eq!(config.context_limit(), 150_000); + + // Test model-specific defaults + let config = ModelConfig::new("claude-3-opus".to_string()); + assert_eq!(config.context_limit(), 200_000); + + let config = ModelConfig::new("gpt-4-turbo".to_string()); + assert_eq!(config.context_limit(), 128_000); + + // Test fallback to default + let config = ModelConfig::new("unknown-model".to_string()); + assert_eq!(config.context_limit(), DEFAULT_CONTEXT_LIMIT); + } + + #[test] + fn test_model_config_settings() { + let config = ModelConfig::new("test-model".to_string()) + .with_temperature(Some(0.7)) + .with_max_tokens(Some(1000)) + .with_context_limit(Some(50_000)); + + assert_eq!(config.temperature, Some(0.7)); + assert_eq!(config.max_tokens, Some(1000)); + assert_eq!(config.context_limit, Some(50_000)); + } +} diff --git a/crates/goose/src/prompt_template.rs b/crates/goose/src/prompt_template.rs new file mode 100644 index 00000000..e7652d83 --- /dev/null +++ b/crates/goose/src/prompt_template.rs @@ -0,0 +1,139 @@ +use include_dir::{include_dir, Dir}; +use serde::Serialize; +use std::path::PathBuf; +use tera::{Context, Error as TeraError, Tera}; + +// The prompts directory needs to be embedded in the binary (so it works when distributed) +static PROMPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/prompts"); + +pub fn load_prompt(template: &str, context_data: &T) -> Result { + let mut tera = Tera::default(); + tera.add_raw_template("inline_template", template)?; + let context = Context::from_serialize(context_data)?; + let rendered = tera.render("inline_template", &context)?; + Ok(rendered.trim().to_string()) +} + +pub fn load_prompt_file( + template_file: impl Into, + context_data: &T, +) -> Result { + let template_path = template_file.into(); + + // Get the file content from the embedded directory + let template_content = if let Some(file) = PROMPTS_DIR.get_file(template_path.to_str().unwrap()) + { + String::from_utf8_lossy(file.contents()).into_owned() + } else { + return Err(TeraError::chain( + "Failed to find template file", + std::io::Error::new( + std::io::ErrorKind::NotFound, + "Template file not found in embedded directory", + ), + )); + }; + + load_prompt(&template_content, context_data) +} + +#[cfg(test)] +mod tests { + use super::*; + use mcp_core::tool::Tool; + use serde_json::json; + use std::collections::HashMap; + + #[test] + fn test_load_prompt() { + let template = "Hello, {{ name }}! You are {{ age }} years old."; + let mut context = HashMap::new(); + context.insert("name".to_string(), "Alice".to_string()); + context.insert("age".to_string(), 30.to_string()); + + let result = load_prompt(template, &context).unwrap(); + assert_eq!(result, "Hello, Alice! You are 30 years old."); + } + + #[test] + fn test_load_prompt_missing_variable() { + let template = "Hello, {{ name }}! You are {{ age }} years old."; + let mut context = HashMap::new(); + context.insert("name".to_string(), "Alice".to_string()); + // 'age' is missing from context + let result = load_prompt(template, &context); + assert!(result.is_err()); + } + + #[test] + fn test_load_prompt_file() { + // since we are embedding the prompts directory, the file path needs to be relative to the prompts directory + let file_path = PathBuf::from("mock.md"); + let mut context = HashMap::new(); + context.insert("name".to_string(), "Alice".to_string()); + context.insert("age".to_string(), 30.to_string()); + let result = load_prompt_file(file_path, &context).unwrap(); + assert_eq!( + result, + "This prompt is only used for testing.\n\nHello, Alice! You are 30 years old." + ); + } + + #[test] + fn test_load_prompt_file_missing_file() { + let file_path = PathBuf::from("non_existent_template.txt"); + let context: HashMap = HashMap::new(); // Add type annotation here + + let result = load_prompt_file(file_path, &context); + assert!(result.is_err()); + } + + #[test] + fn test_load_prompt_with_tools() { + let template = "### Tool Descriptions\n{% for tool in tools %}\n{{tool.name}}: {{tool.description}}{% endfor %}"; + + let tools = vec![ + Tool::new( + "calculator", + "Performs basic math operations", + json!({ + "type": "object", + "properties": { + "operation": {"type": "string"}, + "numbers": {"type": "array"} + } + }), + ), + Tool::new( + "weather", + "Gets weather information", + json!({ + "type": "object", + "properties": { + "location": {"type": "string"} + } + }), + ), + ]; + + let mut context = HashMap::new(); + context.insert("tools".to_string(), tools); + + let result = load_prompt(template, &context).unwrap(); + let expected = "### Tool Descriptions\n\ncalculator: Performs basic math operations\nweather: Gets weather information"; + assert_eq!(result, expected); + } + + #[test] + fn test_load_prompt_with_empty_tools() { + let template = "### Tool Descriptions\n{% for tool in tools %}\n{{tool.name}}: {{tool.description}}{% endfor %}"; + + let tools: Vec = vec![]; + let mut context = HashMap::new(); + context.insert("tools".to_string(), tools); + + let result = load_prompt(template, &context).unwrap(); + let expected = "### Tool Descriptions"; + assert_eq!(result, expected); + } +} diff --git a/crates/goose/src/prompts/mock.md b/crates/goose/src/prompts/mock.md new file mode 100644 index 00000000..81b7e309 --- /dev/null +++ b/crates/goose/src/prompts/mock.md @@ -0,0 +1,3 @@ +This prompt is only used for testing. + +Hello, {{ name }}! You are {{ age }} years old. diff --git a/src/goose/synopsis/plan.md b/crates/goose/src/prompts/plan.md similarity index 52% rename from src/goose/synopsis/plan.md rename to crates/goose/src/prompts/plan.md index 8c7fe098..c268d9fd 100644 --- a/src/goose/synopsis/plan.md +++ b/crates/goose/src/prompts/plan.md @@ -1,11 +1,10 @@ -{{synopsis.current_summary}} +You prepare plans for an agent system. You will recieve the current system +status as well as in an incoming request from the human. Your plan will be used by an AI agent, +who is taking actions on behalf of the human. -# Instructions +The agent currently has access to the following tools -Prepare a plan that an agent will use to followup to the request described above. Your plan -will be carried out by an agent with access to the following tools: - -{% for tool in exchange.tools %} +{% for tool in tools %} {{tool.name}}: {{tool.description}}{% endfor %} If the request is simple, such as a greeting or a request for information or advice, the plan can simply be: @@ -23,26 +22,9 @@ Your plan needs to use the following format, but can have any number of tasks. ] ``` -# Hints - -{{synopsis.hints}} - -# Context - -The current state of the agent is: - -{{system.info()}} - -The agent already has access to content of the following files, your plan does not need to include finding or reading these. -However if a file or code object is not here but needs to be viewed to complete the goal, include plan steps to -use `rg` (ripgrep) to find the relevant references. - -{% for file in system.active_files %} -{{file.path}}{% endfor %} - # Examples -These examples show the format you should follow +These examples show the format you should follow. *Do not reply with any other text, just the json plan* ```json [ diff --git a/crates/goose/src/prompts/system.md b/crates/goose/src/prompts/system.md new file mode 100644 index 00000000..97ac8dee --- /dev/null +++ b/crates/goose/src/prompts/system.md @@ -0,0 +1,27 @@ +You are a general purpose AI agent called Goose. You are capable +of dynamically plugging into new extensions and learning how to use them. + +You solve higher level problems using the tools in these extensions, and can +interact with multiple at once. + +{% if (extensions is defined) and extensions %} +Because you dynamically load extensions, your conversation history may refer +to interactions with extensions that are not currently active. The currently +active extensions are below. Each of these extensions provides tools that are +in your tool specification. + +# Extensions: +{% for extension in extensions %} + +## {{extension.name}} +{% if extension.has_resources %} +{{extension.name}} supports resources, you can use platform__read_resource, +and platform__list_resources on this extension. +{% endif %} +{% if extension.instructions %}### Instructions +{{extension.instructions}}{% endif %} +{% endfor %} + +{% else %} +No extensions are defined. You should let the user know that they should add extensions. +{% endif %} \ No newline at end of file diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs new file mode 100644 index 00000000..42cfbe49 --- /dev/null +++ b/crates/goose/src/providers/anthropic.rs @@ -0,0 +1,164 @@ +use anyhow::Result; +use async_trait::async_trait; +use reqwest::{Client, StatusCode}; +use serde_json::Value; +use std::time::Duration; + +use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; +use super::errors::ProviderError; +use super::formats::anthropic::{create_request, get_usage, response_to_message}; +use super::utils::{emit_debug_trace, get_model}; +use crate::message::Message; +use crate::model::ModelConfig; +use mcp_core::tool::Tool; + +pub const ANTHROPIC_DEFAULT_MODEL: &str = "claude-3-5-sonnet-latest"; +pub const ANTHROPIC_KNOWN_MODELS: &[&str] = &[ + "claude-3-5-sonnet-latest", + "claude-3-5-haiku-latest", + "claude-3-opus-latest", +]; + +pub const ANTHROPIC_DOC_URL: &str = "https://docs.anthropic.com/en/docs/about-claude/models"; + +#[derive(serde::Serialize)] +pub struct AnthropicProvider { + #[serde(skip)] + client: Client, + host: String, + api_key: String, + model: ModelConfig, +} + +impl Default for AnthropicProvider { + fn default() -> Self { + let model = ModelConfig::new(AnthropicProvider::metadata().default_model); + AnthropicProvider::from_env(model).expect("Failed to initialize Anthropic provider") + } +} + +impl AnthropicProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let api_key: String = config.get_secret("ANTHROPIC_API_KEY")?; + let host: String = config + .get("ANTHROPIC_HOST") + .unwrap_or_else(|_| "https://api.anthropic.com".to_string()); + + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + Ok(Self { + client, + host, + api_key, + model, + }) + } + + async fn post(&self, payload: Value) -> Result { + let url = format!("{}/v1/messages", self.host.trim_end_matches('/')); + + let response = self + .client + .post(&url) + .header("x-api-key", &self.api_key) + .header("anthropic-version", "2023-06-01") + .json(&payload) + .send() + .await?; + + let status = response.status(); + let payload: Option = response.json().await.ok(); + + // https://docs.anthropic.com/en/api/errors + match status { + StatusCode::OK => payload.ok_or_else( || ProviderError::RequestFailed("Response body is not valid JSON".to_string()) ), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Err(ProviderError::Authentication(format!("Authentication failed. Please ensure your API keys are valid and have the required permissions. \ + Status: {}. Response: {:?}", status, payload))) + } + StatusCode::BAD_REQUEST => { + if let Some(payload) = &payload { + if let Some(error) = payload.get("error") { + tracing::debug!("Bad Request Error: {error:?}"); + let error_msg = error.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown error"); + if error_msg.to_lowercase().contains("too long") || error_msg.to_lowercase().contains("too many") { + return Err(ProviderError::ContextLengthExceeded(error_msg.to_string())); + } + }} + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + StatusCode::TOO_MANY_REQUESTS => { + Err(ProviderError::RateLimitExceeded(format!("{:?}", payload))) + } + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + Err(ProviderError::ServerError(format!("{:?}", payload))) + } + _ => { + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + } + } +} + +#[async_trait] +impl Provider for AnthropicProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "anthropic", + "Anthropic", + "Claude and other models from Anthropic", + ANTHROPIC_DEFAULT_MODEL, + ANTHROPIC_KNOWN_MODELS + .iter() + .map(|&s| s.to_string()) + .collect(), + ANTHROPIC_DOC_URL, + vec![ + ConfigKey::new("ANTHROPIC_API_KEY", true, true, None), + ConfigKey::new( + "ANTHROPIC_HOST", + false, + false, + Some("https://api.anthropic.com"), + ), + ], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let payload = create_request(&self.model, system, messages, tools)?; + + // Make request + let response = self.post(payload.clone()).await?; + + // Parse response + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + + let model = get_model(&response); + emit_debug_trace(self, &payload, &response, &usage); + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs new file mode 100644 index 00000000..53448991 --- /dev/null +++ b/crates/goose/src/providers/base.rs @@ -0,0 +1,181 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use super::errors::ProviderError; +use crate::message::Message; +use crate::model::ModelConfig; +use mcp_core::tool::Tool; + +/// Metadata about a provider's configuration requirements and capabilities +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderMetadata { + /// The unique identifier for this provider + pub name: String, + /// Display name for the provider in UIs + pub display_name: String, + /// Description of the provider's capabilities + pub description: String, + /// The default/recommended model for this provider + pub default_model: String, + /// A list of currently known models + /// TODO: eventually query the apis directly + pub known_models: Vec, + /// Link to the docs where models can be found + pub model_doc_link: String, + /// Required configuration keys + pub config_keys: Vec, +} + +impl ProviderMetadata { + pub fn new( + name: &str, + display_name: &str, + description: &str, + default_model: &str, + known_models: Vec, + model_doc_link: &str, + config_keys: Vec, + ) -> Self { + Self { + name: name.to_string(), + display_name: display_name.to_string(), + description: description.to_string(), + default_model: default_model.to_string(), + known_models, + model_doc_link: model_doc_link.to_string(), + config_keys, + } + } + + pub fn empty() -> Self { + Self { + name: "".to_string(), + display_name: "".to_string(), + description: "".to_string(), + default_model: "".to_string(), + known_models: vec![], + model_doc_link: "".to_string(), + config_keys: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigKey { + pub name: String, + pub required: bool, + pub secret: bool, + pub default: Option, +} + +impl ConfigKey { + pub fn new(name: &str, required: bool, secret: bool, default: Option<&str>) -> Self { + Self { + name: name.to_string(), + required, + secret, + default: default.map(|s| s.to_string()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderUsage { + pub model: String, + pub usage: Usage, +} + +impl ProviderUsage { + pub fn new(model: String, usage: Usage) -> Self { + Self { model, usage } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Usage { + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, +} + +impl Usage { + pub fn new( + input_tokens: Option, + output_tokens: Option, + total_tokens: Option, + ) -> Self { + Self { + input_tokens, + output_tokens, + total_tokens, + } + } +} + +use async_trait::async_trait; + +/// Base trait for AI providers (OpenAI, Anthropic, etc) +#[async_trait] +pub trait Provider: Send + Sync { + /// Get the metadata for this provider type + fn metadata() -> ProviderMetadata + where + Self: Sized; + + /// Generate the next message using the configured model and other parameters + /// + /// # Arguments + /// * `system` - The system prompt that guides the model's behavior + /// * `messages` - The conversation history as a sequence of messages + /// * `tools` - Optional list of tools the model can use + /// + /// # Returns + /// A tuple containing the model's response message and provider usage statistics + /// + /// # Errors + /// ProviderError + /// - It's important to raise ContextLengthExceeded correctly since agent handles it + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError>; + + /// Get the model config from the provider + fn get_model_config(&self) -> ModelConfig; +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + + #[test] + fn test_usage_creation() { + let usage = Usage::new(Some(10), Some(20), Some(30)); + assert_eq!(usage.input_tokens, Some(10)); + assert_eq!(usage.output_tokens, Some(20)); + assert_eq!(usage.total_tokens, Some(30)); + } + + #[test] + fn test_usage_serialization() -> Result<()> { + let usage = Usage::new(Some(10), Some(20), Some(30)); + let serialized = serde_json::to_string(&usage)?; + let deserialized: Usage = serde_json::from_str(&serialized)?; + + assert_eq!(usage.input_tokens, deserialized.input_tokens); + assert_eq!(usage.output_tokens, deserialized.output_tokens); + assert_eq!(usage.total_tokens, deserialized.total_tokens); + + // Test JSON structure + let json_value: serde_json::Value = serde_json::from_str(&serialized)?; + assert_eq!(json_value["input_tokens"], json!(10)); + assert_eq!(json_value["output_tokens"], json!(20)); + assert_eq!(json_value["total_tokens"], json!(30)); + + Ok(()) + } +} diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose/src/providers/databricks.rs new file mode 100644 index 00000000..35811ddf --- /dev/null +++ b/crates/goose/src/providers/databricks.rs @@ -0,0 +1,250 @@ +use anyhow::Result; +use async_trait::async_trait; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::time::Duration; + +use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; +use super::errors::ProviderError; +use super::formats::openai::{create_request, get_usage, response_to_message}; +use super::oauth; +use super::utils::{get_model, ImageFormat}; +use crate::config::ConfigError; +use crate::message::Message; +use crate::model::ModelConfig; +use mcp_core::tool::Tool; + +const DEFAULT_CLIENT_ID: &str = "databricks-cli"; +const DEFAULT_REDIRECT_URL: &str = "http://localhost:8020"; +const DEFAULT_SCOPES: &[&str] = &["all-apis"]; + +pub const DATABRICKS_DEFAULT_MODEL: &str = "databricks-meta-llama-3-3-70b-instruct"; +// Databricks can passthrough to a wide range of models, we only provide the default +pub const DATABRICKS_KNOWN_MODELS: &[&str] = &[ + "databricks-meta-llama-3-3-70b-instruct", + "databricks-meta-llama-3-1-405b-instruct", + "databricks-dbrx-instruct", + "databricks-mixtral-8x7b-instruct", +]; + +pub const DATABRICKS_DOC_URL: &str = + "https://docs.databricks.com/en/generative-ai/external-models/index.html"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DatabricksAuth { + Token(String), + OAuth { + host: String, + client_id: String, + redirect_url: String, + scopes: Vec, + }, +} + +impl DatabricksAuth { + /// Create a new OAuth configuration with default values + pub fn oauth(host: String) -> Self { + Self::OAuth { + host, + client_id: DEFAULT_CLIENT_ID.to_string(), + redirect_url: DEFAULT_REDIRECT_URL.to_string(), + scopes: DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect(), + } + } + pub fn token(token: String) -> Self { + Self::Token(token) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct DatabricksProvider { + #[serde(skip)] + client: Client, + host: String, + auth: DatabricksAuth, + model: ModelConfig, + image_format: ImageFormat, +} + +impl Default for DatabricksProvider { + fn default() -> Self { + let model = ModelConfig::new(DatabricksProvider::metadata().default_model); + DatabricksProvider::from_env(model).expect("Failed to initialize Databricks provider") + } +} + +impl DatabricksProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + + // For compatibility for now we check both config and secret for databricks host + // but it is not actually a secret value + let mut host: Result = config.get("DATABRICKS_HOST"); + + if host.is_err() { + host = config.get_secret("DATABRICKS_HOST") + } + + if host.is_err() { + return Err(ConfigError::NotFound( + "Did not find DATABRICKS_HOST in either config file or keyring".to_string(), + ) + .into()); + } + + let host = host?; + + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + // If we find a databricks token we prefer that + if let Ok(api_key) = config.get_secret("DATABRICKS_TOKEN") { + return Ok(Self { + client, + host, + auth: DatabricksAuth::token(api_key), + model, + image_format: ImageFormat::OpenAi, + }); + } + + // Otherwise use Oauth flow + Ok(Self { + client, + auth: DatabricksAuth::oauth(host.clone()), + host, + model, + image_format: ImageFormat::OpenAi, + }) + } + + async fn ensure_auth_header(&self) -> Result { + match &self.auth { + DatabricksAuth::Token(token) => Ok(format!("Bearer {}", token)), + DatabricksAuth::OAuth { + host, + client_id, + redirect_url, + scopes, + } => { + let token = + oauth::get_oauth_token_async(host, client_id, redirect_url, scopes).await?; + Ok(format!("Bearer {}", token)) + } + } + } + + async fn post(&self, payload: Value) -> Result { + let url = format!( + "{}/serving-endpoints/{}/invocations", + self.host.trim_end_matches('/'), + self.model.model_name + ); + + let auth_header = self.ensure_auth_header().await?; + let response = self + .client + .post(&url) + .header("Authorization", auth_header) + .json(&payload) + .send() + .await?; + + let status = response.status(); + let payload: Option = response.json().await.ok(); + + match status { + StatusCode::OK => payload.ok_or_else( || ProviderError::RequestFailed("Response body is not valid JSON".to_string()) ), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Err(ProviderError::Authentication(format!("Authentication failed. Please ensure your API keys are valid and have the required permissions. \ + Status: {}. Response: {:?}", status, payload))) + } + StatusCode::BAD_REQUEST => { + // Databricks provides a generic 'error' but also includes 'external_model_message' which is provider specific + // we try our best to extract the error message from the payload + let payload_str = serde_json::to_string(&payload).unwrap_or_default(); + if payload_str.contains("too long") + || payload_str.contains("context length") + || payload_str.contains("context_length_exceeded") + || payload_str.contains("reduce the length") + || payload_str.contains("token count") + || payload_str.contains("exceeds") + { + return Err(ProviderError::ContextLengthExceeded(payload_str)); + } + + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + StatusCode::TOO_MANY_REQUESTS => { + Err(ProviderError::RateLimitExceeded(format!("{:?}", payload))) + } + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + Err(ProviderError::ServerError(format!("{:?}", payload))) + } + _ => { + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + } + } +} + +#[async_trait] +impl Provider for DatabricksProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "databricks", + "Databricks", + "Models on Databricks AI Gateway", + DATABRICKS_DEFAULT_MODEL, + DATABRICKS_KNOWN_MODELS + .iter() + .map(|&s| s.to_string()) + .collect(), + DATABRICKS_DOC_URL, + vec![ + ConfigKey::new("DATABRICKS_HOST", true, false, None), + ConfigKey::new("DATABRICKS_TOKEN", false, true, None), + ], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let mut payload = create_request(&self.model, system, messages, tools, &self.image_format)?; + // Remove the model key which is part of the url with databricks + payload + .as_object_mut() + .expect("payload should have model key") + .remove("model"); + + let response = self.post(payload.clone()).await?; + + // Parse response + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + let model = get_model(&response); + super::utils::emit_debug_trace(self, &payload, &response, &usage); + + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/crates/goose/src/providers/errors.rs b/crates/goose/src/providers/errors.rs new file mode 100644 index 00000000..3287866a --- /dev/null +++ b/crates/goose/src/providers/errors.rs @@ -0,0 +1,34 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ProviderError { + #[error("Authentication error: {0}")] + Authentication(String), + + #[error("Context length exceeded: {0}")] + ContextLengthExceeded(String), + + #[error("Rate limit exceeded: {0}")] + RateLimitExceeded(String), + + #[error("Server error: {0}")] + ServerError(String), + + #[error("Request failed: {0}")] + RequestFailed(String), + + #[error("Execution error: {0}")] + ExecutionError(String), +} + +impl From for ProviderError { + fn from(error: anyhow::Error) -> Self { + ProviderError::ExecutionError(error.to_string()) + } +} + +impl From for ProviderError { + fn from(error: reqwest::Error) -> Self { + ProviderError::ExecutionError(error.to_string()) + } +} diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs new file mode 100644 index 00000000..99a257ae --- /dev/null +++ b/crates/goose/src/providers/factory.rs @@ -0,0 +1,37 @@ +use super::{ + anthropic::AnthropicProvider, + base::{Provider, ProviderMetadata}, + databricks::DatabricksProvider, + google::GoogleProvider, + groq::GroqProvider, + ollama::OllamaProvider, + openai::OpenAiProvider, + openrouter::OpenRouterProvider, +}; +use crate::model::ModelConfig; +use anyhow::Result; + +pub fn providers() -> Vec { + vec![ + AnthropicProvider::metadata(), + DatabricksProvider::metadata(), + GoogleProvider::metadata(), + GroqProvider::metadata(), + OllamaProvider::metadata(), + OpenAiProvider::metadata(), + OpenRouterProvider::metadata(), + ] +} + +pub fn create(name: &str, model: ModelConfig) -> Result> { + match name { + "openai" => Ok(Box::new(OpenAiProvider::from_env(model)?)), + "anthropic" => Ok(Box::new(AnthropicProvider::from_env(model)?)), + "databricks" => Ok(Box::new(DatabricksProvider::from_env(model)?)), + "groq" => Ok(Box::new(GroqProvider::from_env(model)?)), + "ollama" => Ok(Box::new(OllamaProvider::from_env(model)?)), + "openrouter" => Ok(Box::new(OpenRouterProvider::from_env(model)?)), + "google" => Ok(Box::new(GoogleProvider::from_env(model)?)), + _ => Err(anyhow::anyhow!("Unknown provider: {}", name)), + } +} diff --git a/crates/goose/src/providers/formats/anthropic.rs b/crates/goose/src/providers/formats/anthropic.rs new file mode 100644 index 00000000..070ad636 --- /dev/null +++ b/crates/goose/src/providers/formats/anthropic.rs @@ -0,0 +1,418 @@ +use crate::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use crate::providers::base::Usage; +use anyhow::{anyhow, Result}; +use mcp_core::content::Content; +use mcp_core::role::Role; +use mcp_core::tool::{Tool, ToolCall}; +use serde_json::{json, Value}; +use std::collections::HashSet; + +/// Convert internal Message format to Anthropic's API message specification +pub fn format_messages(messages: &[Message]) -> Vec { + let mut anthropic_messages = Vec::new(); + + // Convert messages to Anthropic format + for message in messages { + let role = match message.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + + let mut content = Vec::new(); + for msg_content in &message.content { + match msg_content { + MessageContent::Text(text) => { + content.push(json!({ + "type": "text", + "text": text.text + })); + } + MessageContent::ToolRequest(tool_request) => { + if let Ok(tool_call) = &tool_request.tool_call { + content.push(json!({ + "type": "tool_use", + "id": tool_request.id, + "name": tool_call.name, + "input": tool_call.arguments + })); + } + } + MessageContent::ToolResponse(tool_response) => { + if let Ok(result) = &tool_response.tool_result { + let text = result + .iter() + .filter_map(|c| match c { + Content::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect::>() + .join("\n"); + + content.push(json!({ + "type": "tool_result", + "tool_use_id": tool_response.id, + "content": text + })); + } + } + MessageContent::Image(_) => continue, // Anthropic doesn't support image content yet + } + } + + // Skip messages with empty content + if !content.is_empty() { + anthropic_messages.push(json!({ + "role": role, + "content": content + })); + } + } + + // If no messages, add a default one + if anthropic_messages.is_empty() { + anthropic_messages.push(json!({ + "role": "user", + "content": [{ + "type": "text", + "text": "Ignore" + }] + })); + } + + // Add "cache_control" to the last and second-to-last "user" messages. + // During each turn, we mark the final message with cache_control so the conversation can be + // incrementally cached. The second-to-last user message is also marked for caching with the + // cache_control parameter, so that this checkpoint can read from the previous cache. + let mut user_count = 0; + for message in anthropic_messages.iter_mut().rev() { + if message.get("role") == Some(&json!("user")) { + if let Some(content) = message.get_mut("content") { + if let Some(content_array) = content.as_array_mut() { + if let Some(last_content) = content_array.last_mut() { + last_content + .as_object_mut() + .unwrap() + .insert("cache_control".to_string(), json!({ "type": "ephemeral" })); + } + } + } + user_count += 1; + if user_count >= 2 { + break; + } + } + } + + anthropic_messages +} + +/// Convert internal Tool format to Anthropic's API tool specification +pub fn format_tools(tools: &[Tool]) -> Vec { + let mut unique_tools = HashSet::new(); + let mut tool_specs = Vec::new(); + + for tool in tools { + if unique_tools.insert(tool.name.clone()) { + tool_specs.push(json!({ + "name": tool.name, + "description": tool.description, + "input_schema": tool.input_schema + })); + } + } + + // Add "cache_control" to the last tool spec, if any. This means that all tool definitions, + // will be cached as a single prefix. + if let Some(last_tool) = tool_specs.last_mut() { + last_tool + .as_object_mut() + .unwrap() + .insert("cache_control".to_string(), json!({ "type": "ephemeral" })); + } + + tool_specs +} + +/// Convert system message to Anthropic's API system specification +pub fn format_system(system: &str) -> Value { + json!([{ + "type": "text", + "text": system, + "cache_control": { "type": "ephemeral" } + }]) +} + +/// Convert Anthropic's API response to internal Message format +pub fn response_to_message(response: Value) -> Result { + let content_blocks = response + .get("content") + .and_then(|c| c.as_array()) + .ok_or_else(|| anyhow!("Invalid response format: missing content array"))?; + + let mut message = Message::assistant(); + + for block in content_blocks { + match block.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + message = message.with_text(text.to_string()); + } + } + Some("tool_use") => { + let id = block + .get("id") + .and_then(|i| i.as_str()) + .ok_or_else(|| anyhow!("Missing tool_use id"))?; + let name = block + .get("name") + .and_then(|n| n.as_str()) + .ok_or_else(|| anyhow!("Missing tool_use name"))?; + let input = block + .get("input") + .ok_or_else(|| anyhow!("Missing tool_use input"))?; + + let tool_call = ToolCall::new(name, input.clone()); + message = message.with_tool_request(id, Ok(tool_call)); + } + _ => continue, + } + } + + Ok(message) +} + +/// Extract usage information from Anthropic's API response +pub fn get_usage(data: &Value) -> Result { + // Extract usage data if available + if let Some(usage) = data.get("usage") { + let input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + let output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + let total_tokens = match (input_tokens, output_tokens) { + (Some(i), Some(o)) => Some(i + o), + _ => None, + }; + + Ok(Usage::new(input_tokens, output_tokens, total_tokens)) + } else { + // If no usage data, return None for all values + Ok(Usage::new(None, None, None)) + } +} + +/// Create a complete request payload for Anthropic's API +pub fn create_request( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], +) -> Result { + let anthropic_messages = format_messages(messages); + let tool_specs = format_tools(tools); + let system_spec = format_system(system); + + // Check if we have any messages to send + if anthropic_messages.is_empty() { + return Err(anyhow!("No valid messages to send to Anthropic API")); + } + + let mut payload = json!({ + "model": model_config.model_name, + "messages": anthropic_messages, + "max_tokens": model_config.max_tokens.unwrap_or(4096) + }); + + // Add system message if present + if !system.is_empty() { + payload + .as_object_mut() + .unwrap() + .insert("system".to_string(), json!(system_spec)); + } + + // Add tools if present + if !tool_specs.is_empty() { + payload + .as_object_mut() + .unwrap() + .insert("tools".to_string(), json!(tool_specs)); + } + + // Add temperature if specified + if let Some(temp) = model_config.temperature { + payload + .as_object_mut() + .unwrap() + .insert("temperature".to_string(), json!(temp)); + } + + Ok(payload) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_parse_text_response() -> Result<()> { + let response = json!({ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [{ + "type": "text", + "text": "Hello! How can I assist you today?" + }], + "model": "claude-3-5-sonnet-latest", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 15, + "cache_creation_input_tokens": 12, + "cache_read_input_tokens": 0 + } + }); + + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + + if let MessageContent::Text(text) = &message.content[0] { + assert_eq!(text.text, "Hello! How can I assist you today?"); + } else { + panic!("Expected Text content"); + } + + assert_eq!(usage.input_tokens, Some(12)); + assert_eq!(usage.output_tokens, Some(15)); + assert_eq!(usage.total_tokens, Some(27)); + + Ok(()) + } + + #[test] + fn test_parse_tool_response() -> Result<()> { + let response = json!({ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "tool_1", + "name": "calculator", + "input": { + "expression": "2 + 2" + } + }], + "model": "claude-3-sonnet-20240229", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 15, + "output_tokens": 20, + "cache_creation_input_tokens": 15, + "cache_read_input_tokens": 0, + } + }); + + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + + if let MessageContent::ToolRequest(tool_request) = &message.content[0] { + let tool_call = tool_request.tool_call.as_ref().unwrap(); + assert_eq!(tool_call.name, "calculator"); + assert_eq!(tool_call.arguments, json!({"expression": "2 + 2"})); + } else { + panic!("Expected ToolRequest content"); + } + + assert_eq!(usage.input_tokens, Some(15)); + assert_eq!(usage.output_tokens, Some(20)); + assert_eq!(usage.total_tokens, Some(35)); + + Ok(()) + } + + #[test] + fn test_message_to_anthropic_spec() { + let messages = vec![ + Message::user().with_text("Hello"), + Message::assistant().with_text("Hi there"), + Message::user().with_text("How are you?"), + ]; + + let spec = format_messages(&messages); + + assert_eq!(spec.len(), 3); + assert_eq!(spec[0]["role"], "user"); + assert_eq!(spec[0]["content"][0]["type"], "text"); + assert_eq!(spec[0]["content"][0]["text"], "Hello"); + assert_eq!(spec[1]["role"], "assistant"); + assert_eq!(spec[1]["content"][0]["text"], "Hi there"); + assert_eq!(spec[2]["role"], "user"); + assert_eq!(spec[2]["content"][0]["text"], "How are you?"); + } + + #[test] + fn test_tools_to_anthropic_spec() { + let tools = vec![ + Tool::new( + "calculator", + "Calculate mathematical expressions", + json!({ + "type": "object", + "properties": { + "expression": { + "type": "string", + "description": "The mathematical expression to evaluate" + } + } + }), + ), + Tool::new( + "weather", + "Get weather information", + json!({ + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The location to get weather for" + } + } + }), + ), + ]; + + let spec = format_tools(&tools); + + assert_eq!(spec.len(), 2); + assert_eq!(spec[0]["name"], "calculator"); + assert_eq!(spec[0]["description"], "Calculate mathematical expressions"); + assert_eq!(spec[1]["name"], "weather"); + assert_eq!(spec[1]["description"], "Get weather information"); + + // Verify cache control is added to last tool + assert!(spec[1].get("cache_control").is_some()); + } + + #[test] + fn test_system_to_anthropic_spec() { + let system = "You are a helpful assistant."; + let spec = format_system(system); + + assert!(spec.is_array()); + let spec_array = spec.as_array().unwrap(); + assert_eq!(spec_array.len(), 1); + assert_eq!(spec_array[0]["type"], "text"); + assert_eq!(spec_array[0]["text"], system); + assert!(spec_array[0].get("cache_control").is_some()); + } +} diff --git a/crates/goose/src/providers/formats/google.rs b/crates/goose/src/providers/formats/google.rs new file mode 100644 index 00000000..01060f70 --- /dev/null +++ b/crates/goose/src/providers/formats/google.rs @@ -0,0 +1,531 @@ +use crate::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use crate::providers::base::Usage; +use crate::providers::utils::{is_valid_function_name, sanitize_function_name}; +use anyhow::Result; +use mcp_core::content::Content; +use mcp_core::role::Role; +use mcp_core::tool::{Tool, ToolCall}; +use serde_json::{json, Map, Value}; + +/// Convert internal Message format to Google's API message specification +pub fn format_messages(messages: &[Message]) -> Vec { + messages + .iter() + .map(|message| { + let role = if message.role == Role::User { + "user" + } else { + "model" + }; + let mut parts = Vec::new(); + for message_content in message.content.iter() { + match message_content { + MessageContent::Text(text) => { + if !text.text.is_empty() { + parts.push(json!({"text": text.text})); + } + } + MessageContent::ToolRequest(request) => match &request.tool_call { + Ok(tool_call) => { + let mut function_call_part = Map::new(); + function_call_part.insert( + "name".to_string(), + json!(sanitize_function_name(&tool_call.name)), + ); + if tool_call.arguments.is_object() + && !tool_call.arguments.as_object().unwrap().is_empty() + { + function_call_part + .insert("args".to_string(), tool_call.arguments.clone()); + } + parts.push(json!({ + "functionCall": function_call_part + })); + } + Err(e) => { + parts.push(json!({"text":format!("Error: {}", e)})); + } + }, + MessageContent::ToolResponse(response) => { + match &response.tool_result { + Ok(contents) => { + // Send only contents with no audience or with Assistant in the audience + let abridged: Vec<_> = contents + .iter() + .filter(|content| { + content.audience().is_none_or(|audience| { + audience.contains(&Role::Assistant) + }) + }) + .map(|content| content.unannotated()) + .collect(); + + for content in abridged { + match content { + Content::Image(image) => { + parts.push(json!({ + "inline_data": { + "mime_type": image.mime_type, + "data": image.data, + } + })); + } + _ => { + parts.push(json!({ + "functionResponse": { + "name": response.id, + "response": {"content": content}, + }} + )); + } + } + } + } + Err(e) => { + parts.push(json!({"text":format!("Error: {}", e)})); + } + } + } + + _ => {} + } + } + json!({"role": role, "parts": parts}) + }) + .collect() +} + +/// Convert internal Tool format to Google's API tool specification +pub fn format_tools(tools: &[Tool]) -> Vec { + tools + .iter() + .map(|tool| { + let mut parameters = Map::new(); + parameters.insert("name".to_string(), json!(tool.name)); + parameters.insert("description".to_string(), json!(tool.description)); + let tool_input_schema = tool.input_schema.as_object().unwrap(); + let tool_input_schema_properties = tool_input_schema + .get("properties") + .unwrap_or(&json!({})) + .as_object() + .unwrap() + .clone(); + if !tool_input_schema_properties.is_empty() { + let accepted_tool_schema_attributes = vec![ + "type".to_string(), + "format".to_string(), + "description".to_string(), + "nullable".to_string(), + "enum".to_string(), + "maxItems".to_string(), + "minItems".to_string(), + "properties".to_string(), + "required".to_string(), + "items".to_string(), + ]; + parameters.insert( + "parameters".to_string(), + json!(process_map( + tool_input_schema, + &accepted_tool_schema_attributes, + None + )), + ); + } + json!(parameters) + }) + .collect() +} + +/// Process a JSON map to filter out unsupported attributes +fn process_map( + map: &Map, + accepted_keys: &[String], + parent_key: Option<&str>, +) -> Value { + let mut filtered_map: Map = map + .iter() + .filter_map(|(key, value)| { + let should_remove = !accepted_keys.contains(key) && parent_key != Some("properties"); + if should_remove { + return None; + } + // Process nested maps recursively + let filtered_value = match value { + Value::Object(nested_map) => process_map( + &nested_map + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + accepted_keys, + Some(key), + ), + _ => value.clone(), + }; + + Some((key.clone(), filtered_value)) + }) + .collect(); + if parent_key != Some("properties") && !filtered_map.contains_key("type") { + filtered_map.insert("type".to_string(), Value::String("string".to_string())); + } + + Value::Object(filtered_map) +} + +/// Convert Google's API response to internal Message format +pub fn response_to_message(response: Value) -> Result { + let mut content = Vec::new(); + let binding = vec![]; + let candidates: &Vec = response + .get("candidates") + .and_then(|v| v.as_array()) + .unwrap_or(&binding); + let candidate = candidates.first(); + let role = Role::Assistant; + let created = chrono::Utc::now().timestamp(); + if candidate.is_none() { + return Ok(Message { + role, + created, + content, + }); + } + let candidate = candidate.unwrap(); + let parts = candidate + .get("content") + .and_then(|content| content.get("parts")) + .and_then(|parts| parts.as_array()) + .unwrap_or(&binding); + for part in parts { + if let Some(text) = part.get("text").and_then(|v| v.as_str()) { + content.push(MessageContent::text(text.to_string())); + } else if let Some(function_call) = part.get("functionCall") { + let id = function_call["name"] + .as_str() + .unwrap_or_default() + .to_string(); + let name = function_call["name"] + .as_str() + .unwrap_or_default() + .to_string(); + if !is_valid_function_name(&name) { + let error = mcp_core::ToolError::NotFound(format!( + "The provided function name '{}' had invalid characters, it must match this regex [a-zA-Z0-9_-]+", + name + )); + content.push(MessageContent::tool_request(id, Err(error))); + } else { + let parameters = function_call.get("args"); + if let Some(params) = parameters { + content.push(MessageContent::tool_request( + id, + Ok(ToolCall::new(&name, params.clone())), + )); + } + } + } + } + Ok(Message { + role, + created, + content, + }) +} + +/// Extract usage information from Google's API response +pub fn get_usage(data: &Value) -> Result { + if let Some(usage_meta_data) = data.get("usageMetadata") { + let input_tokens = usage_meta_data + .get("promptTokenCount") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + let output_tokens = usage_meta_data + .get("candidatesTokenCount") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + let total_tokens = usage_meta_data + .get("totalTokenCount") + .and_then(|v| v.as_u64()) + .map(|v| v as i32); + Ok(Usage::new(input_tokens, output_tokens, total_tokens)) + } else { + // If no usage data, return None for all values + Ok(Usage::new(None, None, None)) + } +} + +/// Create a complete request payload for Google's API +pub fn create_request( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], +) -> Result { + let mut payload = Map::new(); + payload.insert( + "system_instruction".to_string(), + json!({"parts": [{"text": system}]}), + ); + payload.insert("contents".to_string(), json!(format_messages(messages))); + if !tools.is_empty() { + payload.insert( + "tools".to_string(), + json!({"functionDeclarations": format_tools(tools)}), + ); + } + let mut generation_config = Map::new(); + if let Some(temp) = model_config.temperature { + generation_config.insert("temperature".to_string(), json!(temp)); + } + if let Some(tokens) = model_config.max_tokens { + generation_config.insert("maxOutputTokens".to_string(), json!(tokens)); + } + if !generation_config.is_empty() { + payload.insert("generationConfig".to_string(), json!(generation_config)); + } + + Ok(Value::Object(payload)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn set_up_text_message(text: &str, role: Role) -> Message { + Message { + role, + created: 0, + content: vec![MessageContent::text(text.to_string())], + } + } + + fn set_up_tool_request_message(id: &str, tool_call: ToolCall) -> Message { + Message { + role: Role::User, + created: 0, + content: vec![MessageContent::tool_request(id.to_string(), Ok(tool_call))], + } + } + + fn set_up_tool_response_message(id: &str, tool_response: Vec) -> Message { + Message { + role: Role::Assistant, + created: 0, + content: vec![MessageContent::tool_response( + id.to_string(), + Ok(tool_response), + )], + } + } + + fn set_up_tool(name: &str, description: &str, params: Value) -> Tool { + Tool { + name: name.to_string(), + description: description.to_string(), + input_schema: json!({ + "properties": params + }), + } + } + + #[test] + fn test_get_usage() { + let data = json!({ + "usageMetadata": { + "promptTokenCount": 1, + "candidatesTokenCount": 2, + "totalTokenCount": 3 + } + }); + let usage = get_usage(&data).unwrap(); + assert_eq!(usage.input_tokens, Some(1)); + assert_eq!(usage.output_tokens, Some(2)); + assert_eq!(usage.total_tokens, Some(3)); + } + + #[test] + fn test_message_to_google_spec_text_message() { + let messages = vec![ + set_up_text_message("Hello", Role::User), + set_up_text_message("World", Role::Assistant), + ]; + let payload = format_messages(&messages); + assert_eq!(payload.len(), 2); + assert_eq!(payload[0]["role"], "user"); + assert_eq!(payload[0]["parts"][0]["text"], "Hello"); + assert_eq!(payload[1]["role"], "model"); + assert_eq!(payload[1]["parts"][0]["text"], "World"); + } + + #[test] + fn test_message_to_google_spec_tool_request_message() { + let arguments = json!({ + "param1": "value1" + }); + let messages = vec![set_up_tool_request_message( + "id", + ToolCall::new("tool_name", json!(arguments)), + )]; + let payload = format_messages(&messages); + assert_eq!(payload.len(), 1); + assert_eq!(payload[0]["role"], "user"); + assert_eq!(payload[0]["parts"][0]["functionCall"]["args"], arguments); + } + + #[test] + fn test_message_to_google_spec_tool_result_message() { + let tool_result: Vec = vec![Content::text("Hello")]; + let messages = vec![set_up_tool_response_message("response_id", tool_result)]; + let payload = format_messages(&messages); + assert_eq!(payload.len(), 1); + assert_eq!(payload[0]["role"], "model"); + assert_eq!( + payload[0]["parts"][0]["functionResponse"]["name"], + "response_id" + ); + assert_eq!( + payload[0]["parts"][0]["functionResponse"]["response"]["content"]["text"], + "Hello" + ); + } + + #[test] + fn test_tools_to_google_spec_with_valid_tools() { + let params1 = json!({ + "param1": { + "type": "string", + "description": "A parameter", + "field_does_not_accept": ["value1", "value2"] + } + }); + let params2 = json!({ + "param2": { + "type": "string", + "description": "B parameter", + } + }); + let tools = vec![ + set_up_tool("tool1", "description1", params1), + set_up_tool("tool2", "description2", params2), + ]; + let result = format_tools(&tools); + assert_eq!(result.len(), 2); + assert_eq!(result[0]["name"], "tool1"); + assert_eq!(result[0]["description"], "description1"); + assert_eq!( + result[0]["parameters"]["properties"], + json!({"param1": json!({ + "type": "string", + "description": "A parameter" + })}) + ); + assert_eq!(result[1]["name"], "tool2"); + assert_eq!(result[1]["description"], "description2"); + assert_eq!( + result[1]["parameters"]["properties"], + json!({"param2": json!({ + "type": "string", + "description": "B parameter" + })}) + ); + } + + #[test] + fn test_tools_to_google_spec_with_empty_properties() { + let tools = vec![Tool { + name: "tool1".to_string(), + description: "description1".to_string(), + input_schema: json!({ + "properties": {} + }), + }]; + let result = format_tools(&tools); + assert_eq!(result.len(), 1); + assert_eq!(result[0]["name"], "tool1"); + assert_eq!(result[0]["description"], "description1"); + assert!(result[0]["parameters"].get("properties").is_none()); + } + + #[test] + fn test_response_to_message_with_no_candidates() { + let response = json!({}); + let message = response_to_message(response).unwrap(); + assert_eq!(message.role, Role::Assistant); + assert!(message.content.is_empty()); + } + + #[test] + fn test_response_to_message_with_text_part() { + let response = json!({ + "candidates": [{ + "content": { + "parts": [{ + "text": "Hello, world!" + }] + } + }] + }); + let message = response_to_message(response).unwrap(); + assert_eq!(message.role, Role::Assistant); + assert_eq!(message.content.len(), 1); + if let MessageContent::Text(text) = &message.content[0] { + assert_eq!(text.text, "Hello, world!"); + } else { + panic!("Expected text content"); + } + } + + #[test] + fn test_response_to_message_with_invalid_function_name() { + let response = json!({ + "candidates": [{ + "content": { + "parts": [{ + "functionCall": { + "name": "invalid name!", + "args": {} + } + }] + } + }] + }); + let message = response_to_message(response).unwrap(); + assert_eq!(message.role, Role::Assistant); + assert_eq!(message.content.len(), 1); + if let Err(error) = &message.content[0].as_tool_request().unwrap().tool_call { + assert!(matches!(error, mcp_core::ToolError::NotFound(_))); + } else { + panic!("Expected tool request error"); + } + } + + #[test] + fn test_response_to_message_with_valid_function_call() { + let response = json!({ + "candidates": [{ + "content": { + "parts": [{ + "functionCall": { + "name": "valid_name", + "args": { + "param": "value" + } + } + }] + } + }] + }); + let message = response_to_message(response).unwrap(); + assert_eq!(message.role, Role::Assistant); + assert_eq!(message.content.len(), 1); + if let Ok(tool_call) = &message.content[0].as_tool_request().unwrap().tool_call { + assert_eq!(tool_call.name, "valid_name"); + assert_eq!(tool_call.arguments["param"], "value"); + } else { + panic!("Expected valid tool request"); + } + } +} diff --git a/crates/goose/src/providers/formats/mod.rs b/crates/goose/src/providers/formats/mod.rs new file mode 100644 index 00000000..71346828 --- /dev/null +++ b/crates/goose/src/providers/formats/mod.rs @@ -0,0 +1,3 @@ +pub mod anthropic; +pub mod google; +pub mod openai; diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs new file mode 100644 index 00000000..b61f270a --- /dev/null +++ b/crates/goose/src/providers/formats/openai.rs @@ -0,0 +1,561 @@ +use crate::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use crate::providers::base::Usage; +use crate::providers::utils::{ + convert_image, is_valid_function_name, sanitize_function_name, ImageFormat, +}; +use anyhow::{anyhow, Error}; +use mcp_core::ToolError; +use mcp_core::{Content, Role, Tool, ToolCall}; +use serde_json::{json, Value}; + +/// Convert internal Message format to OpenAI's API message specification +/// some openai compatible endpoints use the anthropic image spec at the content level +/// even though the message structure is otherwise following openai, the enum switches this +pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec { + let mut messages_spec = Vec::new(); + for message in messages { + let mut converted = json!({ + "role": message.role + }); + + let mut output = Vec::new(); + + for content in &message.content { + match content { + MessageContent::Text(text) => { + if !text.text.is_empty() { + converted["content"] = json!(text.text); + } + } + MessageContent::ToolRequest(request) => match &request.tool_call { + Ok(tool_call) => { + let sanitized_name = sanitize_function_name(&tool_call.name); + let tool_calls = converted + .as_object_mut() + .unwrap() + .entry("tool_calls") + .or_insert(json!([])); + + tool_calls.as_array_mut().unwrap().push(json!({ + "id": request.id, + "type": "function", + "function": { + "name": sanitized_name, + "arguments": tool_call.arguments.to_string(), + } + })); + } + Err(e) => { + output.push(json!({ + "role": "tool", + "content": format!("Error: {}", e), + "tool_call_id": request.id + })); + } + }, + MessageContent::ToolResponse(response) => { + match &response.tool_result { + Ok(contents) => { + // Send only contents with no audience or with Assistant in the audience + let abridged: Vec<_> = contents + .iter() + .filter(|content| { + content + .audience() + .is_none_or(|audience| audience.contains(&Role::Assistant)) + }) + .map(|content| content.unannotated()) + .collect(); + + // Process all content, replacing images with placeholder text + let mut tool_content = Vec::new(); + let mut image_messages = Vec::new(); + + for content in abridged { + match content { + Content::Image(image) => { + // Add placeholder text in the tool response + tool_content.push(Content::text("This tool result included an image that is uploaded in the next message.")); + + // Create a separate image message + image_messages.push(json!({ + "role": "user", + "content": [convert_image(&image, image_format)] + })); + } + Content::Resource(resource) => { + tool_content.push(Content::text(resource.get_text())); + } + _ => { + tool_content.push(content); + } + } + } + let tool_response_content: Value = json!(tool_content + .iter() + .map(|content| match content { + Content::Text(text) => text.text.clone(), + _ => String::new(), + }) + .collect::>() + .join(" ")); + + // First add the tool response with all content + output.push(json!({ + "role": "tool", + "content": tool_response_content, + "tool_call_id": response.id + })); + // Then add any image messages that need to follow + output.extend(image_messages); + } + Err(e) => { + // A tool result error is shown as output so the model can interpret the error message + output.push(json!({ + "role": "tool", + "content": format!("The tool call returned the following error:\n{}", e), + "tool_call_id": response.id + })); + } + } + } + MessageContent::Image(image) => { + // Handle direct image content + converted["content"] = json!([convert_image(image, image_format)]); + } + } + } + + if converted.get("content").is_some() || converted.get("tool_calls").is_some() { + output.insert(0, converted); + } + messages_spec.extend(output); + } + + messages_spec +} + +/// Convert internal Tool format to OpenAI's API tool specification +pub fn format_tools(tools: &[Tool]) -> anyhow::Result> { + let mut tool_names = std::collections::HashSet::new(); + let mut result = Vec::new(); + + for tool in tools { + if !tool_names.insert(&tool.name) { + return Err(anyhow!("Duplicate tool name: {}", tool.name)); + } + + // OpenAI's tool description max str len is 1024 + result.push(json!({ + "type": "function", + "function": { + "name": tool.name, + "description": tool.description.clone().truncate(1024), + "parameters": tool.input_schema, + } + })); + } + + Ok(result) +} + +/// Convert OpenAI's API response to internal Message format +pub fn response_to_message(response: Value) -> anyhow::Result { + let original = response["choices"][0]["message"].clone(); + let mut content = Vec::new(); + + if let Some(text) = original.get("content") { + if let Some(text_str) = text.as_str() { + content.push(MessageContent::text(text_str)); + } + } + + if let Some(tool_calls) = original.get("tool_calls") { + if let Some(tool_calls_array) = tool_calls.as_array() { + for tool_call in tool_calls_array { + let id = tool_call["id"].as_str().unwrap_or_default().to_string(); + let function_name = tool_call["function"]["name"] + .as_str() + .unwrap_or_default() + .to_string(); + let arguments = tool_call["function"]["arguments"] + .as_str() + .unwrap_or_default() + .to_string(); + + if !is_valid_function_name(&function_name) { + let error = ToolError::NotFound(format!( + "The provided function name '{}' had invalid characters, it must match this regex [a-zA-Z0-9_-]+", + function_name + )); + content.push(MessageContent::tool_request(id, Err(error))); + } else { + match serde_json::from_str::(&arguments) { + Ok(params) => { + content.push(MessageContent::tool_request( + id, + Ok(ToolCall::new(&function_name, params)), + )); + } + Err(e) => { + let error = ToolError::InvalidParameters(format!( + "Could not interpret tool use parameters for id {}: {}", + id, e + )); + content.push(MessageContent::tool_request(id, Err(error))); + } + } + } + } + } + } + + Ok(Message { + role: Role::Assistant, + created: chrono::Utc::now().timestamp(), + content, + }) +} + +pub fn get_usage(data: &Value) -> anyhow::Result { + let usage = data + .get("usage") + .ok_or_else(|| anyhow!("No usage data in response"))?; + + let input_tokens = usage + .get("prompt_tokens") + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + + let output_tokens = usage + .get("completion_tokens") + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + + let total_tokens = usage + .get("total_tokens") + .and_then(|v| v.as_i64()) + .map(|v| v as i32) + .or_else(|| match (input_tokens, output_tokens) { + (Some(input), Some(output)) => Some(input + output), + _ => None, + }); + + Ok(Usage::new(input_tokens, output_tokens, total_tokens)) +} + +pub fn create_request( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + image_format: &ImageFormat, +) -> anyhow::Result { + let system_message = json!({ + "role": "system", + "content": system + }); + + let messages_spec = format_messages(messages, image_format); + let tools_spec = if !tools.is_empty() { + format_tools(tools)? + } else { + vec![] + }; + + let mut messages_array = vec![system_message]; + messages_array.extend(messages_spec); + + let mut payload = json!({ + "model": model_config.model_name, + "messages": messages_array + }); + + if !tools_spec.is_empty() { + payload + .as_object_mut() + .unwrap() + .insert("tools".to_string(), json!(tools_spec)); + } + if let Some(temp) = model_config.temperature { + payload + .as_object_mut() + .unwrap() + .insert("temperature".to_string(), json!(temp)); + } + if let Some(tokens) = model_config.max_tokens { + payload + .as_object_mut() + .unwrap() + .insert("max_tokens".to_string(), json!(tokens)); + } + Ok(payload) +} + +#[cfg(test)] +mod tests { + use super::*; + use mcp_core::content::Content; + use serde_json::json; + + const OPENAI_TOOL_USE_RESPONSE: &str = r#"{ + "choices": [{ + "role": "assistant", + "message": { + "tool_calls": [{ + "id": "1", + "function": { + "name": "example_fn", + "arguments": "{\"param\": \"value\"}" + } + }] + } + }], + "usage": { + "input_tokens": 10, + "output_tokens": 25, + "total_tokens": 35 + } + }"#; + + #[test] + fn test_format_messages() -> anyhow::Result<()> { + let message = Message::user().with_text("Hello"); + let spec = format_messages(&[message], &ImageFormat::OpenAi); + + assert_eq!(spec.len(), 1); + assert_eq!(spec[0]["role"], "user"); + assert_eq!(spec[0]["content"], "Hello"); + Ok(()) + } + + #[test] + fn test_format_tools() -> anyhow::Result<()> { + let tool = Tool::new( + "test_tool", + "A test tool", + json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Test parameter" + } + }, + "required": ["input"] + }), + ); + + let spec = format_tools(&[tool])?; + + assert_eq!(spec.len(), 1); + assert_eq!(spec[0]["type"], "function"); + assert_eq!(spec[0]["function"]["name"], "test_tool"); + Ok(()) + } + + #[test] + fn test_format_messages_complex() -> anyhow::Result<()> { + let mut messages = vec![ + Message::assistant().with_text("Hello!"), + Message::user().with_text("How are you?"), + Message::assistant().with_tool_request( + "tool1", + Ok(ToolCall::new("example", json!({"param1": "value1"}))), + ), + ]; + + // Get the ID from the tool request to use in the response + let tool_id = if let MessageContent::ToolRequest(request) = &messages[2].content[0] { + request.id.clone() + } else { + panic!("should be tool request"); + }; + + messages + .push(Message::user().with_tool_response(tool_id, Ok(vec![Content::text("Result")]))); + + let spec = format_messages(&messages, &ImageFormat::OpenAi); + + assert_eq!(spec.len(), 4); + assert_eq!(spec[0]["role"], "assistant"); + assert_eq!(spec[0]["content"], "Hello!"); + assert_eq!(spec[1]["role"], "user"); + assert_eq!(spec[1]["content"], "How are you?"); + assert_eq!(spec[2]["role"], "assistant"); + assert!(spec[2]["tool_calls"].is_array()); + assert_eq!(spec[3]["role"], "tool"); + assert_eq!(spec[3]["content"], "Result"); + assert_eq!(spec[3]["tool_call_id"], spec[2]["tool_calls"][0]["id"]); + + Ok(()) + } + + #[test] + fn test_format_messages_multiple_content() -> anyhow::Result<()> { + let mut messages = vec![Message::assistant().with_tool_request( + "tool1", + Ok(ToolCall::new("example", json!({"param1": "value1"}))), + )]; + + // Get the ID from the tool request to use in the response + let tool_id = if let MessageContent::ToolRequest(request) = &messages[0].content[0] { + request.id.clone() + } else { + panic!("should be tool request"); + }; + + messages + .push(Message::user().with_tool_response(tool_id, Ok(vec![Content::text("Result")]))); + + let spec = format_messages(&messages, &ImageFormat::OpenAi); + + assert_eq!(spec.len(), 2); + assert_eq!(spec[0]["role"], "assistant"); + assert!(spec[0]["tool_calls"].is_array()); + assert_eq!(spec[1]["role"], "tool"); + assert_eq!(spec[1]["content"], "Result"); + assert_eq!(spec[1]["tool_call_id"], spec[0]["tool_calls"][0]["id"]); + + Ok(()) + } + + #[test] + fn test_format_tools_duplicate() -> anyhow::Result<()> { + let tool1 = Tool::new( + "test_tool", + "Test tool", + json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Test parameter" + } + }, + "required": ["input"] + }), + ); + + let tool2 = Tool::new( + "test_tool", + "Test tool", + json!({ + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Test parameter" + } + }, + "required": ["input"] + }), + ); + + let result = format_tools(&[tool1, tool2]); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Duplicate tool name")); + + Ok(()) + } + + #[test] + fn test_format_tools_empty() -> anyhow::Result<()> { + let spec = format_tools(&[])?; + assert!(spec.is_empty()); + Ok(()) + } + + #[test] + fn test_response_to_message_text() -> anyhow::Result<()> { + let response = json!({ + "choices": [{ + "role": "assistant", + "message": { + "content": "Hello from John Cena!" + } + }], + "usage": { + "input_tokens": 10, + "output_tokens": 25, + "total_tokens": 35 + } + }); + + let message = response_to_message(response)?; + assert_eq!(message.content.len(), 1); + if let MessageContent::Text(text) = &message.content[0] { + assert_eq!(text.text, "Hello from John Cena!"); + } else { + panic!("Expected Text content"); + } + assert!(matches!(message.role, Role::Assistant)); + + Ok(()) + } + + #[test] + fn test_response_to_message_valid_toolrequest() -> anyhow::Result<()> { + let response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; + let message = response_to_message(response)?; + + assert_eq!(message.content.len(), 1); + if let MessageContent::ToolRequest(request) = &message.content[0] { + let tool_call = request.tool_call.as_ref().unwrap(); + assert_eq!(tool_call.name, "example_fn"); + assert_eq!(tool_call.arguments, json!({"param": "value"})); + } else { + panic!("Expected ToolRequest content"); + } + + Ok(()) + } + + #[test] + fn test_response_to_message_invalid_func_name() -> anyhow::Result<()> { + let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; + response["choices"][0]["message"]["tool_calls"][0]["function"]["name"] = + json!("invalid fn"); + + let message = response_to_message(response)?; + + if let MessageContent::ToolRequest(request) = &message.content[0] { + match &request.tool_call { + Err(ToolError::NotFound(msg)) => { + assert!(msg.starts_with("The provided function name")); + } + _ => panic!("Expected ToolNotFound error"), + } + } else { + panic!("Expected ToolRequest content"); + } + + Ok(()) + } + + #[test] + fn test_response_to_message_json_decode_error() -> anyhow::Result<()> { + let mut response: Value = serde_json::from_str(OPENAI_TOOL_USE_RESPONSE)?; + response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] = + json!("invalid json {"); + + let message = response_to_message(response)?; + + if let MessageContent::ToolRequest(request) = &message.content[0] { + match &request.tool_call { + Err(ToolError::InvalidParameters(msg)) => { + assert!(msg.starts_with("Could not interpret tool use parameters")); + } + _ => panic!("Expected InvalidParameters error"), + } + } else { + panic!("Expected ToolRequest content"); + } + + Ok(()) + } +} diff --git a/crates/goose/src/providers/google.rs b/crates/goose/src/providers/google.rs new file mode 100644 index 00000000..0c20159c --- /dev/null +++ b/crates/goose/src/providers/google.rs @@ -0,0 +1,166 @@ +use super::errors::ProviderError; +use crate::message::Message; +use crate::model::ModelConfig; +use crate::providers::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; +use crate::providers::formats::google::{create_request, get_usage, response_to_message}; +use crate::providers::utils::{emit_debug_trace, unescape_json_values}; +use anyhow::Result; +use async_trait::async_trait; +use mcp_core::tool::Tool; +use reqwest::{Client, StatusCode}; +use serde_json::Value; +use std::time::Duration; + +pub const GOOGLE_API_HOST: &str = "https://generativelanguage.googleapis.com"; +pub const GOOGLE_DEFAULT_MODEL: &str = "gemini-2.0-flash-exp"; +pub const GOOGLE_KNOWN_MODELS: &[&str] = &[ + "models/gemini-1.5-pro-latest", + "models/gemini-1.5-pro", + "models/gemini-1.5-flash-latest", + "models/gemini-1.5-flash", + "models/gemini-2.0-flash-exp", + "models/gemini-2.0-flash-thinking-exp-01-21", +]; + +pub const GOOGLE_DOC_URL: &str = "https://ai.google/get-started/our-models/"; + +#[derive(Debug, serde::Serialize)] +pub struct GoogleProvider { + #[serde(skip)] + client: Client, + host: String, + api_key: String, + model: ModelConfig, +} + +impl Default for GoogleProvider { + fn default() -> Self { + let model = ModelConfig::new(GoogleProvider::metadata().default_model); + GoogleProvider::from_env(model).expect("Failed to initialize Google provider") + } +} + +impl GoogleProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let api_key: String = config.get_secret("GOOGLE_API_KEY")?; + let host: String = config + .get("GOOGLE_HOST") + .unwrap_or_else(|_| GOOGLE_API_HOST.to_string()); + + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + Ok(Self { + client, + host, + api_key, + model, + }) + } + + async fn post(&self, payload: Value) -> Result { + let url = format!( + "{}/v1beta/models/{}:generateContent?key={}", + self.host.trim_end_matches('/'), + self.model.model_name, + self.api_key + ); + + let response = self + .client + .post(&url) + .header("CONTENT_TYPE", "application/json") + .json(&payload) + .send() + .await?; + + let status = response.status(); + let payload: Option = response.json().await.ok(); + + match status { + StatusCode::OK => payload.ok_or_else( || ProviderError::RequestFailed("Response body is not valid JSON".to_string()) ), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Err(ProviderError::Authentication(format!("Authentication failed. Please ensure your API keys are valid and have the required permissions. \ + Status: {}. Response: {:?}", status, payload ))) + } + StatusCode::BAD_REQUEST => { + if let Some(payload) = &payload { + if let Some(error) = payload.get("error") { + let error_msg = error.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown error"); + let error_status = error.get("status").and_then(|s| s.as_str()).unwrap_or("Unknown status"); + if error_status == "INVALID_ARGUMENT" && error_msg.to_lowercase().contains("exceeds") { + return Err(ProviderError::ContextLengthExceeded(error_msg.to_string())); + } + } + } + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + StatusCode::TOO_MANY_REQUESTS => { + Err(ProviderError::RateLimitExceeded(format!("{:?}", payload))) + } + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + Err(ProviderError::ServerError(format!("{:?}", payload))) + } + _ => { + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + } + } +} + +#[async_trait] +impl Provider for GoogleProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "google", + "Google Gemini", + "Gemini models from Google AI", + GOOGLE_DEFAULT_MODEL, + GOOGLE_KNOWN_MODELS.iter().map(|&s| s.to_string()).collect(), + GOOGLE_DOC_URL, + vec![ + ConfigKey::new("GOOGLE_API_KEY", true, true, None), + ConfigKey::new("GOOGLE_HOST", false, false, Some(GOOGLE_API_HOST)), + ], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let payload = create_request(&self.model, system, messages, tools)?; + + // Make request + let response = self.post(payload.clone()).await?; + + // Parse response + let message = response_to_message(unescape_json_values(&response))?; + let usage = get_usage(&response)?; + let model = match response.get("modelVersion") { + Some(model_version) => model_version.as_str().unwrap_or_default().to_string(), + None => self.model.model_name.clone(), + }; + emit_debug_trace(self, &payload, &response, &usage); + let provider_usage = ProviderUsage::new(model, usage); + Ok((message, provider_usage)) + } +} diff --git a/crates/goose/src/providers/groq.rs b/crates/goose/src/providers/groq.rs new file mode 100644 index 00000000..2fcb5672 --- /dev/null +++ b/crates/goose/src/providers/groq.rs @@ -0,0 +1,145 @@ +use super::errors::ProviderError; +use crate::message::Message; +use crate::model::ModelConfig; +use crate::providers::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; +use crate::providers::formats::openai::{create_request, get_usage, response_to_message}; +use crate::providers::utils::get_model; +use anyhow::Result; +use async_trait::async_trait; +use mcp_core::Tool; +use reqwest::{Client, StatusCode}; +use serde_json::Value; +use std::time::Duration; + +pub const GROQ_API_HOST: &str = "https://api.groq.com"; +pub const GROQ_DEFAULT_MODEL: &str = "llama-3.3-70b-versatile"; +pub const GROQ_KNOWN_MODELS: &[&str] = &["gemma2-9b-it", "llama-3.3-70b-versatile"]; + +pub const GROQ_DOC_URL: &str = "https://console.groq.com/docs/models"; + +#[derive(serde::Serialize)] +pub struct GroqProvider { + #[serde(skip)] + client: Client, + host: String, + api_key: String, + model: ModelConfig, +} + +impl Default for GroqProvider { + fn default() -> Self { + let model = ModelConfig::new(GroqProvider::metadata().default_model); + GroqProvider::from_env(model).expect("Failed to initialize Groq provider") + } +} + +impl GroqProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let api_key: String = config.get_secret("GROQ_API_KEY")?; + let host: String = config + .get("GROQ_HOST") + .unwrap_or_else(|_| GROQ_API_HOST.to_string()); + + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + Ok(Self { + client, + host, + api_key, + model, + }) + } + + async fn post(&self, payload: Value) -> anyhow::Result { + let url = format!( + "{}/openai/v1/chat/completions", + self.host.trim_end_matches('/') + ); + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&payload) + .send() + .await?; + + let status = response.status(); + let payload: Option = response.json().await.ok(); + + match status { + StatusCode::OK => payload.ok_or_else( || ProviderError::RequestFailed("Response body is not valid JSON".to_string()) ), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Err(ProviderError::Authentication(format!("Authentication failed. Please ensure your API keys are valid and have the required permissions. \ + Status: {}. Response: {:?}", status, payload))) + } + StatusCode::PAYLOAD_TOO_LARGE => { + Err(ProviderError::ContextLengthExceeded(format!("{:?}", payload))) + } + StatusCode::TOO_MANY_REQUESTS => { + Err(ProviderError::RateLimitExceeded(format!("{:?}", payload))) + } + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + Err(ProviderError::ServerError(format!("{:?}", payload))) + } + _ => { + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + } + } +} + +#[async_trait] +impl Provider for GroqProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "groq", + "Groq", + "Fast inference with Groq hardware", + GROQ_DEFAULT_MODEL, + GROQ_KNOWN_MODELS.iter().map(|&s| s.to_string()).collect(), + GROQ_DOC_URL, + vec![ + ConfigKey::new("GROQ_API_KEY", true, true, None), + ConfigKey::new("GROQ_HOST", false, false, Some(GROQ_API_HOST)), + ], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { + let payload = create_request( + &self.model, + system, + messages, + tools, + &super::utils::ImageFormat::OpenAi, + )?; + + let response = self.post(payload.clone()).await?; + + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + let model = get_model(&response); + super::utils::emit_debug_trace(self, &payload, &response, &usage); + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs new file mode 100644 index 00000000..52448a58 --- /dev/null +++ b/crates/goose/src/providers/mod.rs @@ -0,0 +1,15 @@ +pub mod anthropic; +pub mod base; +pub mod databricks; +pub mod errors; +mod factory; +pub mod formats; +pub mod google; +pub mod groq; +pub mod oauth; +pub mod ollama; +pub mod openai; +pub mod openrouter; +pub mod utils; + +pub use factory::{create, providers}; diff --git a/crates/goose/src/providers/oauth.rs b/crates/goose/src/providers/oauth.rs new file mode 100644 index 00000000..00bc6673 --- /dev/null +++ b/crates/goose/src/providers/oauth.rs @@ -0,0 +1,369 @@ +use anyhow::Result; +use axum::{extract::Query, response::Html, routing::get, Router}; +use base64::Engine; +use chrono::{DateTime, Utc}; +use lazy_static::lazy_static; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::Digest; +use std::{collections::HashMap, fs, net::SocketAddr, path::PathBuf, sync::Arc}; +use tokio::sync::{oneshot, Mutex as TokioMutex}; + +lazy_static! { + static ref OAUTH_MUTEX: TokioMutex<()> = TokioMutex::new(()); +} +use url::Url; + +#[derive(Debug, Clone)] +struct OidcEndpoints { + authorization_endpoint: String, + token_endpoint: String, +} + +#[derive(Serialize, Deserialize)] +struct TokenData { + access_token: String, + expires_at: Option>, +} + +struct TokenCache { + cache_path: PathBuf, +} + +fn get_base_path() -> PathBuf { + const BASE_PATH: &str = ".config/goose/databricks/oauth"; + let home_dir = std::env::var("HOME").expect("HOME environment variable not set"); + PathBuf::from(home_dir).join(BASE_PATH) +} + +impl TokenCache { + fn new(host: &str, client_id: &str, scopes: &[String]) -> Self { + let mut hasher = sha2::Sha256::new(); + hasher.update(host.as_bytes()); + hasher.update(client_id.as_bytes()); + hasher.update(scopes.join(",").as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + + fs::create_dir_all(get_base_path()).unwrap(); + let cache_path = get_base_path().join(format!("{}.json", hash)); + + Self { cache_path } + } + + fn load_token(&self) -> Option { + if let Ok(contents) = fs::read_to_string(&self.cache_path) { + if let Ok(token_data) = serde_json::from_str::(&contents) { + if let Some(expires_at) = token_data.expires_at { + if expires_at > Utc::now() { + return Some(token_data); + } + } else { + return Some(token_data); + } + } + } + None + } + + fn save_token(&self, token_data: &TokenData) -> Result<()> { + if let Some(parent) = self.cache_path.parent() { + fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string(token_data)?; + fs::write(&self.cache_path, contents)?; + Ok(()) + } +} + +async fn get_workspace_endpoints(host: &str) -> Result { + let host = host.trim_end_matches('/'); + let oidc_url = format!("{}/oidc/.well-known/oauth-authorization-server", host); + + let client = reqwest::Client::new(); + let resp = client.get(&oidc_url).send().await?; + + if !resp.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to get OIDC configuration from {}", + oidc_url + )); + } + + let oidc_config: Value = resp.json().await?; + + let authorization_endpoint = oidc_config + .get("authorization_endpoint") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("authorization_endpoint not found in OIDC configuration"))? + .to_string(); + + let token_endpoint = oidc_config + .get("token_endpoint") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("token_endpoint not found in OIDC configuration"))? + .to_string(); + + Ok(OidcEndpoints { + authorization_endpoint, + token_endpoint, + }) +} + +struct OAuthFlow { + endpoints: OidcEndpoints, + client_id: String, + redirect_url: String, + scopes: Vec, + state: String, + verifier: String, +} + +impl OAuthFlow { + fn new( + endpoints: OidcEndpoints, + client_id: String, + redirect_url: String, + scopes: Vec, + ) -> Self { + Self { + endpoints, + client_id, + redirect_url, + scopes, + state: nanoid::nanoid!(16), + verifier: nanoid::nanoid!(64), + } + } + + fn get_authorization_url(&self) -> String { + let challenge = { + let digest = sha2::Sha256::digest(self.verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) + }; + + let params = [ + ("response_type", "code"), + ("client_id", &self.client_id), + ("redirect_uri", &self.redirect_url), + ("scope", &self.scopes.join(" ")), + ("state", &self.state), + ("code_challenge", &challenge), + ("code_challenge_method", "S256"), + ]; + + format!( + "{}?{}", + self.endpoints.authorization_endpoint, + serde_urlencoded::to_string(params).unwrap() + ) + } + + async fn exchange_code_for_token(&self, code: &str) -> Result { + let params = [ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", &self.redirect_url), + ("code_verifier", &self.verifier), + ("client_id", &self.client_id), + ]; + + let client = reqwest::Client::new(); + let resp = client + .post(&self.endpoints.token_endpoint) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let err_text = resp.text().await?; + return Err(anyhow::anyhow!( + "Failed to exchange code for token: {}", + err_text + )); + } + + let token_response: Value = resp.json().await?; + let access_token = token_response + .get("access_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("access_token not found in token response"))? + .to_string(); + + let expires_in = token_response + .get("expires_in") + .and_then(|v| v.as_u64()) + .unwrap_or(3600); + + let expires_at = Utc::now() + chrono::Duration::seconds(expires_in as i64); + + Ok(TokenData { + access_token, + expires_at: Some(expires_at), + }) + } + + async fn execute(&self) -> Result { + // Create a channel that will send the auth code from the app process + let (tx, rx) = oneshot::channel(); + let state = self.state.clone(); + // Axum can theoretically spawn multiple threads, so we need this to be in an Arc even + // though it will ultimately only get used once + let tx = Arc::new(tokio::sync::Mutex::new(Some(tx))); + + // Setup a server that will recieve the redirect, capture the code, and display success/failure + let app = Router::new().route( + "/", + get(move |Query(params): Query>| { + let tx = Arc::clone(&tx); + let state = state.clone(); + async move { + let code = params.get("code").cloned(); + let received_state = params.get("state").cloned(); + + if let (Some(code), Some(received_state)) = (code, received_state) { + if received_state == state { + if let Some(sender) = tx.lock().await.take() { + if sender.send(code).is_ok() { + // Use the improved HTML response + return Html( + "

Login Success

You can close this window

", + ); + } + } + Html("

Error

Authentication already completed.

") + } else { + Html("

Error

State mismatch.

") + } + } else { + Html("

Error

Authentication failed.

") + } + } + }), + ); + + // Start the server to accept the oauth code + let redirect_url = Url::parse(&self.redirect_url)?; + let port = redirect_url.port().unwrap_or(80); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + + let listener = tokio::net::TcpListener::bind(addr).await?; + + let server_handle = tokio::spawn(async move { + let server = axum::serve(listener, app); + server.await.unwrap(); + }); + + // Open the browser which will redirect with the code to the server + let authorization_url = self.get_authorization_url(); + if webbrowser::open(&authorization_url).is_err() { + println!( + "Please open this URL in your browser:\n{}", + authorization_url + ); + } + + // Wait for the authorization code with a timeout + let code = tokio::time::timeout( + std::time::Duration::from_secs(60), // 1 minute timeout + rx, + ) + .await + .map_err(|_| anyhow::anyhow!("Authentication timed out"))??; + + // Stop the server + server_handle.abort(); + + // Exchange the code for a token + self.exchange_code_for_token(&code).await + } +} + +pub(crate) async fn get_oauth_token_async( + host: &str, + client_id: &str, + redirect_url: &str, + scopes: &[String], +) -> Result { + // Acquire the global mutex to ensure only one OAuth flow runs at a time + let _guard = OAUTH_MUTEX.lock().await; + + let token_cache = TokenCache::new(host, client_id, scopes); + + // Try cache first + if let Some(token) = token_cache.load_token() { + return Ok(token.access_token); + } + + // Get endpoints and execute flow + let endpoints = get_workspace_endpoints(host).await?; + let flow = OAuthFlow::new( + endpoints, + client_id.to_string(), + redirect_url.to_string(), + scopes.to_vec(), + ); + + // Execute the OAuth flow and get token + let token = flow.execute().await?; + + // Cache and return + token_cache.save_token(&token)?; + Ok(token.access_token) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + #[tokio::test] + async fn test_get_workspace_endpoints() -> Result<()> { + let mock_server = MockServer::start().await; + + let mock_response = serde_json::json!({ + "authorization_endpoint": "https://example.com/oauth2/authorize", + "token_endpoint": "https://example.com/oauth2/token" + }); + + Mock::given(method("GET")) + .and(path("/oidc/.well-known/oauth-authorization-server")) + .respond_with(ResponseTemplate::new(200).set_body_json(&mock_response)) + .mount(&mock_server) + .await; + + let endpoints = get_workspace_endpoints(&mock_server.uri()).await?; + + assert_eq!( + endpoints.authorization_endpoint, + "https://example.com/oauth2/authorize" + ); + assert_eq!(endpoints.token_endpoint, "https://example.com/oauth2/token"); + + Ok(()) + } + + #[test] + fn test_token_cache() -> Result<()> { + let cache = TokenCache::new( + "https://example.com", + "test-client", + &["scope1".to_string()], + ); + + let token_data = TokenData { + access_token: "test-token".to_string(), + expires_at: Some(Utc::now() + chrono::Duration::hours(1)), + }; + + cache.save_token(&token_data)?; + + let loaded_token = cache.load_token().unwrap(); + assert_eq!(loaded_token.access_token, token_data.access_token); + + Ok(()) + } +} diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs new file mode 100644 index 00000000..63db1fec --- /dev/null +++ b/crates/goose/src/providers/ollama.rs @@ -0,0 +1,112 @@ +use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; +use super::errors::ProviderError; +use super::utils::{get_model, handle_response_openai_compat}; +use crate::message::Message; +use crate::model::ModelConfig; +use crate::providers::formats::openai::{create_request, get_usage, response_to_message}; +use anyhow::Result; +use async_trait::async_trait; +use mcp_core::tool::Tool; +use reqwest::Client; +use serde_json::Value; +use std::time::Duration; + +pub const OLLAMA_HOST: &str = "http://localhost:11434"; +pub const OLLAMA_DEFAULT_MODEL: &str = "qwen2.5"; +// Ollama can run many models, we only provide the default +pub const OLLAMA_KNOWN_MODELS: &[&str] = &[OLLAMA_DEFAULT_MODEL]; +pub const OLLAMA_DOC_URL: &str = "https://ollama.com/library"; + +#[derive(serde::Serialize)] +pub struct OllamaProvider { + #[serde(skip)] + client: Client, + host: String, + model: ModelConfig, +} + +impl Default for OllamaProvider { + fn default() -> Self { + let model = ModelConfig::new(OllamaProvider::metadata().default_model); + OllamaProvider::from_env(model).expect("Failed to initialize Ollama provider") + } +} + +impl OllamaProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let host: String = config + .get("OLLAMA_HOST") + .unwrap_or_else(|_| OLLAMA_HOST.to_string()); + + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + Ok(Self { + client, + host, + model, + }) + } + + async fn post(&self, payload: Value) -> Result { + let url = format!("{}/v1/chat/completions", self.host.trim_end_matches('/')); + + let response = self.client.post(&url).json(&payload).send().await?; + + handle_response_openai_compat(response).await + } +} + +#[async_trait] +impl Provider for OllamaProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "ollama", + "Ollama", + "Local open source models", + OLLAMA_DEFAULT_MODEL, + OLLAMA_KNOWN_MODELS.iter().map(|&s| s.to_string()).collect(), + OLLAMA_DOC_URL, + vec![ConfigKey::new( + "OLLAMA_HOST", + false, + false, + Some(OLLAMA_HOST), + )], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let payload = create_request( + &self.model, + system, + messages, + tools, + &super::utils::ImageFormat::OpenAi, + )?; + + let response = self.post(payload.clone()).await?; + + // Parse response + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + let model = get_model(&response); + super::utils::emit_debug_trace(self, &payload, &response, &usage); + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs new file mode 100644 index 00000000..b35ae7c2 --- /dev/null +++ b/crates/goose/src/providers/openai.rs @@ -0,0 +1,123 @@ +use anyhow::Result; +use async_trait::async_trait; +use reqwest::Client; +use serde_json::Value; +use std::time::Duration; + +use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; +use super::errors::ProviderError; +use super::formats::openai::{create_request, get_usage, response_to_message}; +use super::utils::{emit_debug_trace, get_model, handle_response_openai_compat, ImageFormat}; +use crate::message::Message; +use crate::model::ModelConfig; +use mcp_core::tool::Tool; + +pub const OPEN_AI_DEFAULT_MODEL: &str = "gpt-4o"; +pub const OPEN_AI_KNOWN_MODELS: &[&str] = &[ + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-3.5-turbo", + "o1", + "o1-mini", +]; + +pub const OPEN_AI_DOC_URL: &str = "https://platform.openai.com/docs/models"; + +#[derive(Debug, serde::Serialize)] +pub struct OpenAiProvider { + #[serde(skip)] + client: Client, + host: String, + api_key: String, + model: ModelConfig, +} + +impl Default for OpenAiProvider { + fn default() -> Self { + let model = ModelConfig::new(OpenAiProvider::metadata().default_model); + OpenAiProvider::from_env(model).expect("Failed to initialize OpenAI provider") + } +} + +impl OpenAiProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let api_key: String = config.get_secret("OPENAI_API_KEY")?; + let host: String = config + .get("OPENAI_HOST") + .unwrap_or_else(|_| "https://api.openai.com".to_string()); + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + Ok(Self { + client, + host, + api_key, + model, + }) + } + + async fn post(&self, payload: Value) -> Result { + let url = format!("{}/v1/chat/completions", self.host.trim_end_matches('/')); + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&payload) + .send() + .await?; + + handle_response_openai_compat(response).await + } +} + +#[async_trait] +impl Provider for OpenAiProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "openai", + "OpenAI", + "GPT-4 and other OpenAI models", + OPEN_AI_DEFAULT_MODEL, + OPEN_AI_KNOWN_MODELS + .iter() + .map(|&s| s.to_string()) + .collect(), + OPEN_AI_DOC_URL, + vec![ + ConfigKey::new("OPENAI_API_KEY", true, true, None), + ConfigKey::new("OPENAI_HOST", false, false, Some("https://api.openai.com")), + ], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let payload = create_request(&self.model, system, messages, tools, &ImageFormat::OpenAi)?; + + // Make request + let response = self.post(payload.clone()).await?; + + // Parse response + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + let model = get_model(&response); + emit_debug_trace(self, &payload, &response, &usage); + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose/src/providers/openrouter.rs new file mode 100644 index 00000000..f8fb3f2c --- /dev/null +++ b/crates/goose/src/providers/openrouter.rs @@ -0,0 +1,211 @@ +use anyhow::{Error, Result}; +use async_trait::async_trait; +use reqwest::Client; +use serde_json::{json, Value}; +use std::time::Duration; + +use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage}; +use super::errors::ProviderError; +use super::utils::{emit_debug_trace, get_model, handle_response_openai_compat}; +use crate::message::Message; +use crate::model::ModelConfig; +use crate::providers::formats::openai::{create_request, get_usage, response_to_message}; +use mcp_core::tool::Tool; + +pub const OPENROUTER_DEFAULT_MODEL: &str = "anthropic/claude-3.5-sonnet"; +pub const OPENROUTER_MODEL_PREFIX_ANTHROPIC: &str = "anthropic"; + +// OpenRouter can run many models, we suggest the default +pub const OPENROUTER_KNOWN_MODELS: &[&str] = &[OPENROUTER_DEFAULT_MODEL]; +pub const OPENROUTER_DOC_URL: &str = "https://openrouter.ai/models"; + +#[derive(serde::Serialize)] +pub struct OpenRouterProvider { + #[serde(skip)] + client: Client, + host: String, + api_key: String, + model: ModelConfig, +} + +impl Default for OpenRouterProvider { + fn default() -> Self { + let model = ModelConfig::new(OpenRouterProvider::metadata().default_model); + OpenRouterProvider::from_env(model).expect("Failed to initialize OpenRouter provider") + } +} + +impl OpenRouterProvider { + pub fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + let api_key: String = config.get_secret("OPENROUTER_API_KEY")?; + let host: String = config + .get("OPENROUTER_HOST") + .unwrap_or_else(|_| "https://openrouter.ai".to_string()); + + let client = Client::builder() + .timeout(Duration::from_secs(600)) + .build()?; + + Ok(Self { + client, + host, + api_key, + model, + }) + } + + async fn post(&self, payload: Value) -> Result { + let url = format!( + "{}/api/v1/chat/completions", + self.host.trim_end_matches('/') + ); + + let response = self + .client + .post(&url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("HTTP-Referer", "https://github.com/block/goose") + .header("X-Title", "Goose") + .json(&payload) + .send() + .await?; + + handle_response_openai_compat(response).await + } +} + +/// Update the request when using anthropic model. +/// For anthropic model, we can enable prompt caching to save cost. Since openrouter is the OpenAI compatible +/// endpoint, we need to modify the open ai request to have anthropic cache control field. +fn update_request_for_anthropic(original_payload: &Value) -> Value { + let mut payload = original_payload.clone(); + + if let Some(messages_spec) = payload + .as_object_mut() + .and_then(|obj| obj.get_mut("messages")) + .and_then(|messages| messages.as_array_mut()) + { + // Add "cache_control" to the last and second-to-last "user" messages. + // During each turn, we mark the final message with cache_control so the conversation can be + // incrementally cached. The second-to-last user message is also marked for caching with the + // cache_control parameter, so that this checkpoint can read from the previous cache. + let mut user_count = 0; + for message in messages_spec.iter_mut().rev() { + if message.get("role") == Some(&json!("user")) { + if let Some(content) = message.get_mut("content") { + if let Some(content_str) = content.as_str() { + *content = json!([{ + "type": "text", + "text": content_str, + "cache_control": { "type": "ephemeral" } + }]); + } + } + user_count += 1; + if user_count >= 2 { + break; + } + } + } + + // Update the system message to have cache_control field. + if let Some(system_message) = messages_spec + .iter_mut() + .find(|msg| msg.get("role") == Some(&json!("system"))) + { + if let Some(content) = system_message.get_mut("content") { + if let Some(content_str) = content.as_str() { + *system_message = json!({ + "role": "system", + "content": [{ + "type": "text", + "text": content_str, + "cache_control": { "type": "ephemeral" } + }] + }); + } + } + } + } + payload +} + +fn create_request_based_on_model( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], +) -> anyhow::Result { + let mut payload = create_request( + model_config, + system, + messages, + tools, + &super::utils::ImageFormat::OpenAi, + )?; + + if model_config + .model_name + .starts_with(OPENROUTER_MODEL_PREFIX_ANTHROPIC) + { + payload = update_request_for_anthropic(&payload); + } + + Ok(payload) +} + +#[async_trait] +impl Provider for OpenRouterProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "openrouter", + "OpenRouter", + "Router for many model providers", + OPENROUTER_DEFAULT_MODEL, + OPENROUTER_KNOWN_MODELS + .iter() + .map(|&s| s.to_string()) + .collect(), + OPENROUTER_DOC_URL, + vec![ + ConfigKey::new("OPENROUTER_API_KEY", true, true, None), + ConfigKey::new( + "OPENROUTER_HOST", + false, + false, + Some("https://openrouter.ai"), + ), + ], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + // Create the base payload + let payload = create_request_based_on_model(&self.model, system, messages, tools)?; + + // Make request + let response = self.post(payload.clone()).await?; + + // Parse response + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + let model = get_model(&response); + emit_debug_trace(self, &payload, &response, &usage); + Ok((message, ProviderUsage::new(model, usage))) + } +} diff --git a/crates/goose/src/providers/utils.rs b/crates/goose/src/providers/utils.rs new file mode 100644 index 00000000..4ee0475d --- /dev/null +++ b/crates/goose/src/providers/utils.rs @@ -0,0 +1,231 @@ +use super::base::Usage; +use anyhow::Result; +use regex::Regex; +use reqwest::{Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use crate::providers::errors::ProviderError; +use mcp_core::content::ImageContent; + +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +pub enum ImageFormat { + OpenAi, + Anthropic, +} + +/// Convert an image content into an image json based on format +pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value { + match image_format { + ImageFormat::OpenAi => json!({ + "type": "image_url", + "image_url": { + "url": format!("data:{};base64,{}", image.mime_type, image.data) + } + }), + ImageFormat::Anthropic => json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": image.mime_type, + "data": image.data, + } + }), + } +} + +/// Handle response from OpenAI compatible endpoints +/// Error codes: https://platform.openai.com/docs/guides/error-codes +/// Context window exceeded: https://community.openai.com/t/help-needed-tackling-context-length-limits-in-openai-models/617543 +pub async fn handle_response_openai_compat(response: Response) -> Result { + let status = response.status(); + // Try to parse the response body as JSON (if applicable) + let payload: Option = response.json().await.ok(); + + match status { + StatusCode::OK => payload.ok_or_else( || ProviderError::RequestFailed("Response body is not valid JSON".to_string()) ), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Err(ProviderError::Authentication(format!("Authentication failed. Please ensure your API keys are valid and have the required permissions. \ + Status: {}. Response: {:?}", status, payload))) + } + StatusCode::BAD_REQUEST => { + if let Some(payload) = &payload { + if let Some(error) = payload.get("error") { + tracing::debug!("Bad Request Error: {error:?}"); + if let Some(code) = error.get("code").and_then(|c| c.as_str()) { + if code == "context_length_exceeded" || code == "string_above_max_length" { + let message = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error") + .to_string(); + + + return Err(ProviderError::ContextLengthExceeded(message)); + } + } + }} + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + StatusCode::TOO_MANY_REQUESTS => { + Err(ProviderError::RateLimitExceeded(format!("{:?}", payload))) + } + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + Err(ProviderError::ServerError(format!("{:?}", payload))) + } + _ => { + tracing::debug!( + "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + ); + Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + } + } +} + +pub fn sanitize_function_name(name: &str) -> String { + let re = Regex::new(r"[^a-zA-Z0-9_-]").unwrap(); + re.replace_all(name, "_").to_string() +} + +pub fn is_valid_function_name(name: &str) -> bool { + let re = Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap(); + re.is_match(name) +} + +/// Extract the model name from a JSON object. Common with most providers to have this top level attribute. +pub fn get_model(data: &Value) -> String { + if let Some(model) = data.get("model") { + if let Some(model_str) = model.as_str() { + model_str.to_string() + } else { + "Unknown".to_string() + } + } else { + "Unknown".to_string() + } +} + +pub fn unescape_json_values(value: &Value) -> Value { + match value { + Value::Object(map) => { + let new_map: Map = map + .iter() + .map(|(k, v)| (k.clone(), unescape_json_values(v))) // Process each value + .collect(); + Value::Object(new_map) + } + Value::Array(arr) => { + let new_array: Vec = arr.iter().map(unescape_json_values).collect(); + Value::Array(new_array) + } + Value::String(s) => { + let unescaped = s + .replace("\\\\n", "\n") + .replace("\\\\t", "\t") + .replace("\\\\r", "\r") + .replace("\\\\\"", "\"") + .replace("\\n", "\n") + .replace("\\t", "\t") + .replace("\\r", "\r") + .replace("\\\"", "\""); + Value::String(unescaped) + } + _ => value.clone(), + } +} + +pub fn emit_debug_trace( + model_config: &T, + payload: &impl serde::Serialize, + response: &Value, + usage: &Usage, +) { + // Handle both Map and Value payload types + let payload_str = match serde_json::to_value(payload) { + Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_default(), + Err(_) => serde_json::to_string_pretty(&payload).unwrap_or_default(), + }; + + tracing::debug!( + model_config = %serde_json::to_string_pretty(model_config).unwrap_or_default(), + input = %payload_str, + output = %serde_json::to_string_pretty(response).unwrap_or_default(), + input_tokens = ?usage.input_tokens.unwrap_or_default(), + output_tokens = ?usage.output_tokens.unwrap_or_default(), + total_tokens = ?usage.total_tokens.unwrap_or_default(), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_sanitize_function_name() { + assert_eq!(sanitize_function_name("hello-world"), "hello-world"); + assert_eq!(sanitize_function_name("hello world"), "hello_world"); + assert_eq!(sanitize_function_name("hello@world"), "hello_world"); + } + + #[test] + fn test_is_valid_function_name() { + assert!(is_valid_function_name("hello-world")); + assert!(is_valid_function_name("hello_world")); + assert!(!is_valid_function_name("hello world")); + assert!(!is_valid_function_name("hello@world")); + } + + #[test] + fn unescape_json_values_with_object() { + let value = json!({"text": "Hello\\nWorld"}); + let unescaped_value = unescape_json_values(&value); + assert_eq!(unescaped_value, json!({"text": "Hello\nWorld"})); + } + + #[test] + fn unescape_json_values_with_array() { + let value = json!(["Hello\\nWorld", "Goodbye\\tWorld"]); + let unescaped_value = unescape_json_values(&value); + assert_eq!(unescaped_value, json!(["Hello\nWorld", "Goodbye\tWorld"])); + } + + #[test] + fn unescape_json_values_with_string() { + let value = json!("Hello\\nWorld"); + let unescaped_value = unescape_json_values(&value); + assert_eq!(unescaped_value, json!("Hello\nWorld")); + } + + #[test] + fn unescape_json_values_with_mixed_content() { + let value = json!({ + "text": "Hello\\nWorld\\\\n!", + "array": ["Goodbye\\tWorld", "See you\\rlater"], + "nested": { + "inner_text": "Inner\\\"Quote\\\"" + } + }); + let unescaped_value = unescape_json_values(&value); + assert_eq!( + unescaped_value, + json!({ + "text": "Hello\nWorld\n!", + "array": ["Goodbye\tWorld", "See you\rlater"], + "nested": { + "inner_text": "Inner\"Quote\"" + } + }) + ); + } + + #[test] + fn unescape_json_values_with_no_escapes() { + let value = json!({"text": "Hello World"}); + let unescaped_value = unescape_json_values(&value); + assert_eq!(unescaped_value, json!({"text": "Hello World"})); + } +} diff --git a/crates/goose/src/token_counter.rs b/crates/goose/src/token_counter.rs new file mode 100644 index 00000000..b719433f --- /dev/null +++ b/crates/goose/src/token_counter.rs @@ -0,0 +1,354 @@ +use include_dir::{include_dir, Dir}; +use mcp_core::Tool; +use std::error::Error; +use std::fs; +use std::path::Path; +use tokenizers::tokenizer::Tokenizer; + +use crate::message::Message; + +// The embedded directory with all possible tokenizer files. +// If one of them doesn’t exist, we’ll download it at startup. +static TOKENIZER_FILES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../tokenizer_files"); + +/// The `TokenCounter` now stores exactly one `Tokenizer`. +pub struct TokenCounter { + tokenizer: Tokenizer, +} + +impl TokenCounter { + /// Creates a new `TokenCounter` using the given HuggingFace tokenizer name. + /// + /// * `tokenizer_name` might look like "Xenova--gpt-4o" + /// or "Qwen--Qwen2.5-Coder-32B-Instruct", etc. + pub fn new(tokenizer_name: &str) -> Self { + match Self::load_from_embedded(tokenizer_name) { + Ok(tokenizer) => Self { tokenizer }, + Err(e) => { + println!( + "Tokenizer '{}' not found in embedded dir: {}", + tokenizer_name, e + ); + println!("Attempting to download tokenizer and load..."); + // Fallback to download tokenizer and load from disk + match Self::download_and_load(tokenizer_name) { + Ok(counter) => counter, + Err(e) => panic!("Failed to initialize tokenizer: {}", e), + } + } + } + } + + /// Load tokenizer bytes from the embedded directory (via `include_dir!`). + fn load_from_embedded(tokenizer_name: &str) -> Result> { + let tokenizer_file_path = format!("{}/tokenizer.json", tokenizer_name); + let file = TOKENIZER_FILES + .get_file(&tokenizer_file_path) + .ok_or_else(|| { + format!( + "Tokenizer file not found in embedded: {}", + tokenizer_file_path + ) + })?; + let contents = file.contents(); + let tokenizer = Tokenizer::from_bytes(contents) + .map_err(|e| format!("Failed to parse tokenizer bytes: {}", e))?; + Ok(tokenizer) + } + + /// Fallback: If not found in embedded, we look in `base_dir` on disk. + /// If not on disk, we download from Hugging Face, then load from disk. + fn download_and_load(tokenizer_name: &str) -> Result> { + let local_dir = std::env::temp_dir().join(tokenizer_name); + let local_json_path = local_dir.join("tokenizer.json"); + + // If the file doesn't already exist, we download from HF + if !Path::new(&local_json_path).exists() { + eprintln!("Tokenizer file not on disk, downloading…"); + let repo_id = tokenizer_name.replace("--", "/"); + // e.g. "Xenova--llama3-tokenizer" -> "Xenova/llama3-tokenizer" + Self::download_tokenizer(&repo_id, &local_dir)?; + } + + // Load from disk + let file_content = fs::read(&local_json_path)?; + let tokenizer = Tokenizer::from_bytes(&file_content) + .map_err(|e| format!("Failed to parse tokenizer after download: {}", e))?; + + Ok(Self { tokenizer }) + } + + /// Download from Hugging Face into the local directory if not already present. + /// Synchronous version using a blocking runtime for simplicity. + fn download_tokenizer(repo_id: &str, download_dir: &Path) -> Result<(), Box> { + fs::create_dir_all(download_dir)?; + + let file_url = format!( + "https://huggingface.co/{}/resolve/main/tokenizer.json", + repo_id + ); + let file_path = download_dir.join("tokenizer.json"); + + // Blocking for example: just spawn a short-lived runtime + let content = tokio::runtime::Runtime::new()?.block_on(async { + let response = reqwest::get(&file_url).await?; + if !response.status().is_success() { + let error_msg = + format!("Failed to download tokenizer: status {}", response.status()); + return Err(Box::::from(error_msg)); + } + let bytes = response.bytes().await?; + Ok(bytes) + })?; + + fs::write(&file_path, content)?; + + Ok(()) + } + + /// Count tokens for a piece of text using our single tokenizer. + pub fn count_tokens(&self, text: &str) -> usize { + let encoding = self.tokenizer.encode(text, false).unwrap(); + encoding.len() + } + + fn count_tokens_for_tools(&self, tools: &[Tool]) -> usize { + // Token counts for different function components + let func_init = 7; // Tokens for function initialization + let prop_init = 3; // Tokens for properties initialization + let prop_key = 3; // Tokens for each property key + let enum_init: isize = -3; // Tokens adjustment for enum list start + let enum_item = 3; // Tokens for each enum item + let func_end = 12; // Tokens for function ending + + let mut func_token_count = 0; + if !tools.is_empty() { + for tool in tools { + func_token_count += func_init; // Add tokens for start of each function + let name = &tool.name; + let description = &tool.description.trim_end_matches('.'); + let line = format!("{}:{}", name, description); + func_token_count += self.count_tokens(&line); // Add tokens for name and description + + if let serde_json::Value::Object(properties) = &tool.input_schema["properties"] { + if !properties.is_empty() { + func_token_count += prop_init; // Add tokens for start of properties + for (key, value) in properties { + func_token_count += prop_key; // Add tokens for each property + let p_name = key; + let p_type = value["type"].as_str().unwrap_or(""); + let p_desc = value["description"] + .as_str() + .unwrap_or("") + .trim_end_matches('.'); + let line = format!("{}:{}:{}", p_name, p_type, p_desc); + func_token_count += self.count_tokens(&line); + if let Some(enum_values) = value["enum"].as_array() { + func_token_count = + func_token_count.saturating_add_signed(enum_init); // Add tokens if property has enum list + for item in enum_values { + if let Some(item_str) = item.as_str() { + func_token_count += enum_item; + func_token_count += self.count_tokens(item_str); + } + } + } + } + } + } + } + func_token_count += func_end; + } + + func_token_count + } + + pub fn count_chat_tokens( + &self, + system_prompt: &str, + messages: &[Message], + tools: &[Tool], + ) -> usize { + // <|im_start|>ROLE<|im_sep|>MESSAGE<|im_end|> + let tokens_per_message = 4; + + // Count tokens in the system prompt + let mut num_tokens = 0; + if !system_prompt.is_empty() { + num_tokens += self.count_tokens(system_prompt) + tokens_per_message; + } + + for message in messages { + num_tokens += tokens_per_message; + // Count tokens in the content + for content in &message.content { + // content can either be text response or tool request + if let Some(content_text) = content.as_text() { + num_tokens += self.count_tokens(content_text); + } else if let Some(tool_request) = content.as_tool_request() { + // TODO: count tokens for tool request + let tool_call = tool_request.tool_call.as_ref().unwrap(); + let text = format!( + "{}:{}:{}", + tool_request.id, tool_call.name, tool_call.arguments + ); + num_tokens += self.count_tokens(&text); + } else if let Some(tool_response_text) = content.as_tool_response_text() { + num_tokens += self.count_tokens(&tool_response_text); + } else { + // unsupported content type such as image - pass + continue; + } + } + } + + // Count tokens for tools if provided + if !tools.is_empty() { + num_tokens += self.count_tokens_for_tools(tools); + } + + // Every reply is primed with <|start|>assistant<|message|> + num_tokens += 3; + + num_tokens + } + + pub fn count_everything( + &self, + system_prompt: &str, + messages: &[Message], + tools: &[Tool], + resources: &[String], + ) -> usize { + let mut num_tokens = self.count_chat_tokens(system_prompt, messages, tools); + + if !resources.is_empty() { + for resource in resources { + num_tokens += self.count_tokens(resource); + } + } + num_tokens + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::{Message, MessageContent}; // or however your `Message` is imported + use crate::model::{CLAUDE_TOKENIZER, GPT_4O_TOKENIZER}; + use mcp_core::role::Role; + use mcp_core::tool::Tool; + use serde_json::json; + + #[test] + fn test_claude_tokenizer() { + let counter = TokenCounter::new(CLAUDE_TOKENIZER); + + let text = "Hello, how are you?"; + let count = counter.count_tokens(text); + println!("Token count for '{}': {:?}", text, count); + + // The old test expected 6 tokens + assert_eq!(count, 6, "Claude tokenizer token count mismatch"); + } + + #[test] + fn test_gpt_4o_tokenizer() { + let counter = TokenCounter::new(GPT_4O_TOKENIZER); + + let text = "Hey there!"; + let count = counter.count_tokens(text); + println!("Token count for '{}': {:?}", text, count); + + // The old test expected 3 tokens + assert_eq!(count, 3, "GPT-4o tokenizer token count mismatch"); + } + + #[test] + fn test_count_chat_tokens() { + let counter = TokenCounter::new(GPT_4O_TOKENIZER); + + let system_prompt = + "You are a helpful assistant that can answer questions about the weather."; + + let messages = vec![ + Message { + role: Role::User, + created: 0, + content: vec![MessageContent::text( + "What's the weather like in San Francisco?", + )], + }, + Message { + role: Role::Assistant, + created: 1, + content: vec![MessageContent::text( + "Looks like it's 60 degrees Fahrenheit in San Francisco.", + )], + }, + Message { + role: Role::User, + created: 2, + content: vec![MessageContent::text("How about New York?")], + }, + ]; + + let tools = vec![Tool { + name: "get_current_weather".to_string(), + description: "Get the current weather in a given location".to_string(), + input_schema: json!({ + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + }, + "unit": { + "type": "string", + "description": "The unit of temperature to return", + "enum": ["celsius", "fahrenheit"] + } + }, + "required": ["location"] + }), + }]; + + let token_count_without_tools = counter.count_chat_tokens(system_prompt, &messages, &[]); + println!("Total tokens without tools: {}", token_count_without_tools); + + let token_count_with_tools = counter.count_chat_tokens(system_prompt, &messages, &tools); + println!("Total tokens with tools: {}", token_count_with_tools); + + // The old test used 56 / 124 for GPT-4o. Adjust if your actual tokenizer changes + assert_eq!(token_count_without_tools, 56); + assert_eq!(token_count_with_tools, 124); + } + + #[test] + #[should_panic] + fn test_panic_if_provided_tokenizer_doesnt_exist() { + // This should panic because the tokenizer doesn't exist + // in the embedded directory and the download fails + + TokenCounter::new("nonexistent-tokenizer"); + } + + // Optional test to confirm that fallback download works if not found in embedded: + // Ignored cause this actually downloads a tokenizer from Hugging Face + #[test] + #[ignore] + fn test_download_tokenizer_successfully_if_not_embedded() { + let non_embedded_key = "openai-community/gpt2"; + let counter = TokenCounter::new(non_embedded_key); + + // If it downloads successfully, we can do a quick count to ensure it's valid + let text = "print('hello world')"; + let count = counter.count_tokens(text); + println!( + "Downloaded tokenizer, token count for '{}': {}", + text, count + ); + + // https://tiktokenizer.vercel.app/?model=gpt2 + assert!(count == 5, "Expected 5 tokens from downloaded tokenizer"); + } +} diff --git a/crates/goose/src/tracing/langfuse_layer.rs b/crates/goose/src/tracing/langfuse_layer.rs new file mode 100644 index 00000000..ba41aa9d --- /dev/null +++ b/crates/goose/src/tracing/langfuse_layer.rs @@ -0,0 +1,502 @@ +use crate::tracing::observation_layer::{BatchManager, ObservationLayer, SpanTracker}; +use chrono::Utc; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::env; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use uuid::Uuid; + +const DEFAULT_LANGFUSE_URL: &str = "http://localhost:3000"; + +#[derive(Debug, Serialize, Deserialize)] +struct LangfuseIngestionResponse { + successes: Vec, + errors: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct LangfuseIngestionSuccess { + id: String, + status: i32, +} + +#[derive(Debug, Serialize, Deserialize)] +struct LangfuseIngestionError { + id: String, + status: i32, + message: Option, + error: Option, +} + +#[derive(Debug, Clone)] +pub struct LangfuseBatchManager { + pub batch: Vec, + pub client: Client, + pub base_url: String, + pub public_key: String, + pub secret_key: String, +} + +impl LangfuseBatchManager { + pub fn new(public_key: String, secret_key: String, base_url: String) -> Self { + Self { + batch: Vec::new(), + client: Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("Failed to create HTTP client"), + base_url, + public_key, + secret_key, + } + } + + pub fn spawn_sender(manager: Arc>) { + const BATCH_INTERVAL: Duration = Duration::from_secs(5); + + tokio::spawn(async move { + loop { + tokio::time::sleep(BATCH_INTERVAL).await; + if let Err(e) = manager.lock().await.send() { + tracing::error!( + error.msg = %e, + error.type = %std::any::type_name_of_val(&e), + "Failed to send batch to Langfuse" + ); + } + } + }); + } + + pub async fn send_async(&mut self) -> Result<(), Box> { + if self.batch.is_empty() { + return Ok(()); + } + + let payload = json!({ "batch": self.batch }); + let url = format!("{}/api/public/ingestion", self.base_url); + + let response = self + .client + .post(&url) + .basic_auth(&self.public_key, Some(&self.secret_key)) + .json(&payload) + .send() + .await?; + + match response.status() { + status if status.is_success() => { + let response_body: LangfuseIngestionResponse = response.json().await?; + + for error in &response_body.errors { + tracing::error!( + id = %error.id, + status = error.status, + message = error.message.as_deref().unwrap_or("No message"), + error = ?error.error, + "Partial failure in batch ingestion" + ); + } + + if !response_body.successes.is_empty() { + self.batch.clear(); + } + + if response_body.successes.is_empty() && !response_body.errors.is_empty() { + Err("Langfuse ingestion failed for all items".into()) + } else { + Ok(()) + } + } + status @ (StatusCode::BAD_REQUEST + | StatusCode::UNAUTHORIZED + | StatusCode::FORBIDDEN + | StatusCode::NOT_FOUND + | StatusCode::METHOD_NOT_ALLOWED) => { + let err_text = response.text().await.unwrap_or_default(); + Err(format!("Langfuse API error: {}: {}", status, err_text).into()) + } + status => { + let err_text = response.text().await.unwrap_or_default(); + Err(format!("Unexpected status code: {}: {}", status, err_text).into()) + } + } + } +} + +impl BatchManager for LangfuseBatchManager { + fn add_event(&mut self, event_type: &str, body: Value) { + self.batch.push(json!({ + "id": Uuid::new_v4().to_string(), + "timestamp": Utc::now().to_rfc3339(), + "type": event_type, + "body": body + })); + } + + fn send(&mut self) -> Result<(), Box> { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.send_async()) + }) + } + + fn is_empty(&self) -> bool { + self.batch.is_empty() + } +} + +pub fn create_langfuse_observer() -> Option { + let public_key = env::var("LANGFUSE_PUBLIC_KEY") + .or_else(|_| env::var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY")) + .unwrap_or_default(); // Use empty string if not found + + let secret_key = env::var("LANGFUSE_SECRET_KEY") + .or_else(|_| env::var("LANGFUSE_INIT_PROJECT_SECRET_KEY")) + .unwrap_or_default(); // Use empty string if not found + + // Return None if either key is empty + if public_key.is_empty() || secret_key.is_empty() { + return None; + } + + let base_url = env::var("LANGFUSE_URL").unwrap_or_else(|_| DEFAULT_LANGFUSE_URL.to_string()); + + let batch_manager = Arc::new(Mutex::new(LangfuseBatchManager::new( + public_key, secret_key, base_url, + ))); + + if !cfg!(test) { + LangfuseBatchManager::spawn_sender(batch_manager.clone()); + } + + Some(ObservationLayer { + batch_manager, + span_tracker: Arc::new(Mutex::new(SpanTracker::new())), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + use tokio::sync::Mutex; + use tracing::dispatcher; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + struct TestFixture { + original_subscriber: Option, + original_env_vars: HashMap, + mock_server: Option, + } + + impl TestFixture { + async fn new() -> Self { + Self { + original_subscriber: Some(dispatcher::get_default(dispatcher::Dispatch::clone)), + original_env_vars: Self::save_env_vars(), + mock_server: None, + } + } + + fn save_env_vars() -> HashMap { + [ + "LANGFUSE_PUBLIC_KEY", + "LANGFUSE_INIT_PROJECT_PUBLIC_KEY", + "LANGFUSE_SECRET_KEY", + "LANGFUSE_INIT_PROJECT_SECRET_KEY", + "LANGFUSE_URL", + ] + .iter() + .filter_map(|&var| env::var(var).ok().map(|val| (var.to_string(), val))) + .collect() + } + + async fn with_mock_server(mut self) -> Self { + self.mock_server = Some(MockServer::start().await); + self + } + + fn mock_server_uri(&self) -> String { + self.mock_server + .as_ref() + .expect("Mock server not initialized") + .uri() + } + + async fn mock_response(&self, status: u16, body: Value) { + Mock::given(method("POST")) + .and(path("/api/public/ingestion")) + .respond_with(ResponseTemplate::new(status).set_body_json(body)) + .mount(self.mock_server.as_ref().unwrap()) + .await; + } + } + + impl Drop for TestFixture { + fn drop(&mut self) { + // Restore original subscriber + if let Some(subscriber) = &self.original_subscriber { + let _ = dispatcher::set_global_default(subscriber.clone()); + } + + // Restore environment + for var in [ + "LANGFUSE_PUBLIC_KEY", + "LANGFUSE_INIT_PROJECT_PUBLIC_KEY", + "LANGFUSE_SECRET_KEY", + "LANGFUSE_INIT_PROJECT_SECRET_KEY", + "LANGFUSE_URL", + ] { + if let Some(value) = self.original_env_vars.get(var) { + env::set_var(var, value); + } else { + env::remove_var(var); + } + } + } + } + + fn create_test_event() -> Value { + json!({ + "name": "test_span", + "type": "SPAN" + }) + } + + #[tokio::test] + async fn test_batch_manager_creation() { + let _fixture = TestFixture::new().await; + + let manager = LangfuseBatchManager::new( + "test-public".to_string(), + "test-secret".to_string(), + "http://test.local".to_string(), + ); + + assert_eq!(manager.public_key, "test-public"); + assert_eq!(manager.secret_key, "test-secret"); + assert_eq!(manager.base_url, "http://test.local"); + assert!(manager.batch.is_empty()); + } + + #[tokio::test] + async fn test_add_event() { + let _fixture = TestFixture::new().await; + let mut manager = LangfuseBatchManager::new( + "test-public".to_string(), + "test-secret".to_string(), + "http://test.local".to_string(), + ); + + manager.add_event("test-event", create_test_event()); + + assert_eq!(manager.batch.len(), 1); + let event = &manager.batch[0]; + assert_eq!(event["type"], "test-event"); + assert_eq!(event["body"], create_test_event()); + assert!(event["id"].as_str().is_some()); + assert!(event["timestamp"].as_str().is_some()); + } + + #[tokio::test] + async fn test_batch_send_success() { + let fixture = TestFixture::new().await.with_mock_server().await; + + fixture + .mock_response( + 200, + json!({ + "successes": [{"id": "1", "status": 200}], + "errors": [] + }), + ) + .await; + + let mut manager = LangfuseBatchManager::new( + "test-public".to_string(), + "test-secret".to_string(), + fixture.mock_server_uri(), + ); + + manager.add_event("test-event", create_test_event()); + + let result = manager.send_async().await; + assert!(result.is_ok()); + assert!(manager.batch.is_empty()); + } + + #[tokio::test] + async fn test_batch_send_partial_failure() { + let fixture = TestFixture::new().await.with_mock_server().await; + + fixture + .mock_response( + 200, + json!({ + "successes": [{"id": "1", "status": 200}], + "errors": [{"id": "2", "status": 400, "message": "Invalid data"}] + }), + ) + .await; + + let mut manager = LangfuseBatchManager::new( + "test-public".to_string(), + "test-secret".to_string(), + fixture.mock_server_uri(), + ); + + manager.add_event("test-event", create_test_event()); + + let result = manager.send_async().await; + assert!(result.is_ok()); + assert!(manager.batch.is_empty()); + } + + #[tokio::test] + async fn test_batch_send_complete_failure() { + let fixture = TestFixture::new().await.with_mock_server().await; + + fixture + .mock_response( + 200, + json!({ + "successes": [], + "errors": [{"id": "1", "status": 400, "message": "Invalid data"}] + }), + ) + .await; + + let mut manager = LangfuseBatchManager::new( + "test-public".to_string(), + "test-secret".to_string(), + fixture.mock_server_uri(), + ); + + manager.add_event("test-event", create_test_event()); + + let result = manager.send_async().await; + assert!(result.is_err()); + assert!(!manager.batch.is_empty()); + } + + #[tokio::test] + async fn test_create_langfuse_observer() { + let fixture = TestFixture::new().await.with_mock_server().await; + + // Test 1: No environment variables set - remove all possible variables + for var in &[ + "LANGFUSE_PUBLIC_KEY", + "LANGFUSE_INIT_PROJECT_PUBLIC_KEY", + "LANGFUSE_SECRET_KEY", + "LANGFUSE_INIT_PROJECT_SECRET_KEY", + "LANGFUSE_URL", + ] { + env::remove_var(var); + } + + let observer = create_langfuse_observer(); + assert!( + observer.is_none(), + "Observer should be None without environment variables" + ); + + // Test 2: Only public key set (regular) + env::set_var("LANGFUSE_PUBLIC_KEY", "test-public-key"); + let observer = create_langfuse_observer(); + assert!( + observer.is_none(), + "Observer should be None with only public key" + ); + env::remove_var("LANGFUSE_PUBLIC_KEY"); + + // Test 3: Only secret key set (regular) + env::set_var("LANGFUSE_SECRET_KEY", "test-secret-key"); + let observer = create_langfuse_observer(); + assert!( + observer.is_none(), + "Observer should be None with only secret key" + ); + env::remove_var("LANGFUSE_SECRET_KEY"); + + // Test 4: Only public key set (init project) + env::set_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY", "test-public-key"); + let observer = create_langfuse_observer(); + assert!( + observer.is_none(), + "Observer should be None with only init project public key" + ); + env::remove_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY"); + + // Test 5: Only secret key set (init project) + env::set_var("LANGFUSE_INIT_PROJECT_SECRET_KEY", "test-secret-key"); + let observer = create_langfuse_observer(); + assert!( + observer.is_none(), + "Observer should be None with only init project secret key" + ); + env::remove_var("LANGFUSE_INIT_PROJECT_SECRET_KEY"); + + // Test 6: Both regular keys set (should succeed) + env::set_var("LANGFUSE_PUBLIC_KEY", "test-public-key"); + env::set_var("LANGFUSE_SECRET_KEY", "test-secret-key"); + env::set_var("LANGFUSE_URL", fixture.mock_server_uri()); + let observer = create_langfuse_observer(); + assert!( + observer.is_some(), + "Observer should be Some with both regular keys set" + ); + + // Clean up regular keys + env::remove_var("LANGFUSE_PUBLIC_KEY"); + env::remove_var("LANGFUSE_SECRET_KEY"); + + // Test 7: Both init project keys set (should succeed) + env::set_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY", "test-public-key"); + env::set_var("LANGFUSE_INIT_PROJECT_SECRET_KEY", "test-secret-key"); + let observer = create_langfuse_observer(); + assert!( + observer.is_some(), + "Observer should be Some with both init project keys set" + ); + + // Verify the observer has an empty batch manager + let batch_manager = observer.unwrap().batch_manager; + assert!(batch_manager.lock().await.is_empty()); + } + #[tokio::test] + async fn test_batch_manager_spawn_sender() { + let fixture = TestFixture::new().await.with_mock_server().await; + + fixture + .mock_response( + 200, + json!({ + "successes": [{"id": "1", "status": 200}], + "errors": [] + }), + ) + .await; + + let manager = Arc::new(Mutex::new(LangfuseBatchManager::new( + "test-public".to_string(), + "test-secret".to_string(), + fixture.mock_server_uri(), + ))); + + manager + .lock() + .await + .add_event("test-event", create_test_event()); + + // Instead of spawning the sender which uses blocking operations, + // test the async send directly + let result = manager.lock().await.send_async().await; + assert!(result.is_ok()); + assert!(manager.lock().await.batch.is_empty()); + } +} diff --git a/crates/goose/src/tracing/mod.rs b/crates/goose/src/tracing/mod.rs new file mode 100644 index 00000000..caa6bcd9 --- /dev/null +++ b/crates/goose/src/tracing/mod.rs @@ -0,0 +1,7 @@ +pub mod langfuse_layer; +mod observation_layer; + +pub use langfuse_layer::{create_langfuse_observer, LangfuseBatchManager}; +pub use observation_layer::{ + flatten_metadata, map_level, BatchManager, ObservationLayer, SpanData, SpanTracker, +}; diff --git a/crates/goose/src/tracing/observation_layer.rs b/crates/goose/src/tracing/observation_layer.rs new file mode 100644 index 00000000..08b7c7f3 --- /dev/null +++ b/crates/goose/src/tracing/observation_layer.rs @@ -0,0 +1,520 @@ +use chrono::Utc; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::field::{Field, Visit}; +use tracing::{span, Event, Id, Level, Metadata, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::Layer; +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct SpanData { + pub observation_id: String, // Langfuse requires ids to be UUID v4 strings + pub name: String, + pub start_time: String, + pub level: String, + pub metadata: serde_json::Map, + pub parent_span_id: Option, +} + +pub fn map_level(level: &Level) -> &'static str { + match *level { + Level::ERROR => "ERROR", + Level::WARN => "WARNING", + Level::INFO => "DEFAULT", + Level::DEBUG => "DEBUG", + Level::TRACE => "DEBUG", + } +} + +pub fn flatten_metadata( + metadata: serde_json::Map, +) -> serde_json::Map { + let mut flattened = serde_json::Map::new(); + for (key, value) in metadata { + match value { + Value::String(s) => { + flattened.insert(key, json!(s)); + } + Value::Object(mut obj) => { + if let Some(text) = obj.remove("text") { + flattened.insert(key, text); + } else { + flattened.insert(key, json!(obj)); + } + } + _ => { + flattened.insert(key, value); + } + } + } + flattened +} + +pub trait BatchManager: Send + Sync + 'static { + fn add_event(&mut self, event_type: &str, body: Value); + fn send(&mut self) -> Result<(), Box>; + fn is_empty(&self) -> bool; +} + +#[derive(Debug)] +pub struct SpanTracker { + active_spans: HashMap, // span_id -> observation_id. span_id in Tracing is u64 whereas Langfuse requires UUID v4 strings + current_trace_id: Option, +} + +impl Default for SpanTracker { + fn default() -> Self { + Self::new() + } +} + +impl SpanTracker { + pub fn new() -> Self { + Self { + active_spans: HashMap::new(), + current_trace_id: None, + } + } + + pub fn add_span(&mut self, span_id: u64, observation_id: String) { + self.active_spans.insert(span_id, observation_id); + } + + pub fn get_span(&self, span_id: u64) -> Option<&String> { + self.active_spans.get(&span_id) + } + + pub fn remove_span(&mut self, span_id: u64) -> Option { + self.active_spans.remove(&span_id) + } +} + +#[derive(Clone)] +pub struct ObservationLayer { + pub batch_manager: Arc>, + pub span_tracker: Arc>, +} + +impl ObservationLayer { + pub async fn handle_span(&self, span_id: u64, span_data: SpanData) { + let observation_id = span_data.observation_id.clone(); + + { + let mut spans = self.span_tracker.lock().await; + spans.add_span(span_id, observation_id.clone()); + } + + // Get parent ID if it exists + let parent_id = if let Some(parent_span_id) = span_data.parent_span_id { + let spans = self.span_tracker.lock().await; + spans.get_span(parent_span_id).cloned() + } else { + None + }; + + let trace_id = self.ensure_trace_id().await; + + // Create the span observation + let mut batch = self.batch_manager.lock().await; + batch.add_event( + "observation-create", + json!({ + "id": observation_id, + "traceId": trace_id, + "type": "SPAN", + "name": span_data.name, + "startTime": span_data.start_time, + "parentObservationId": parent_id, + "metadata": span_data.metadata, + "level": span_data.level + }), + ); + } + + pub async fn handle_span_close(&self, span_id: u64) { + let observation_id = { + let mut spans = self.span_tracker.lock().await; + spans.remove_span(span_id) + }; + + if let Some(observation_id) = observation_id { + let trace_id = self.ensure_trace_id().await; + let mut batch = self.batch_manager.lock().await; + batch.add_event( + "observation-update", + json!({ + "id": observation_id, + "type": "SPAN", + "traceId": trace_id, + "endTime": Utc::now().to_rfc3339() + }), + ); + } + } + + pub async fn ensure_trace_id(&self) -> String { + let mut spans = self.span_tracker.lock().await; + if let Some(id) = spans.current_trace_id.clone() { + return id; + } + + let trace_id = Uuid::new_v4().to_string(); + spans.current_trace_id = Some(trace_id.clone()); + + let mut batch = self.batch_manager.lock().await; + batch.add_event( + "trace-create", + json!({ + "id": trace_id, + "name": Utc::now().timestamp().to_string(), + "timestamp": Utc::now().to_rfc3339(), + "input": {}, + "metadata": {}, + "tags": [], + "public": false + }), + ); + + trace_id + } + + pub async fn handle_record(&self, span_id: u64, metadata: serde_json::Map) { + let observation_id = { + let spans = self.span_tracker.lock().await; + spans.get_span(span_id).cloned() + }; + + if let Some(observation_id) = observation_id { + let trace_id = self.ensure_trace_id().await; + + let mut update = json!({ + "id": observation_id, + "traceId": trace_id, + "type": "SPAN" + }); + + // Handle special fields + if let Some(val) = metadata.get("input") { + update["input"] = val.clone(); + } + + if let Some(val) = metadata.get("output") { + update["output"] = val.clone(); + } + + if let Some(val) = metadata.get("model_config") { + update["metadata"] = json!({ "model_config": val }); + } + + // Handle any remaining metadata + let remaining_metadata: serde_json::Map = metadata + .iter() + .filter(|(k, _)| !["input", "output", "model_config"].contains(&k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + if !remaining_metadata.is_empty() { + let flattened = flatten_metadata(remaining_metadata); + if update.get("metadata").is_some() { + // If metadata exists (from model_config), merge with it + if let Some(obj) = update["metadata"].as_object_mut() { + for (k, v) in flattened { + obj.insert(k, v); + } + } + } else { + // Otherwise set it directly + update["metadata"] = json!(flattened); + } + } + + let mut batch = self.batch_manager.lock().await; + batch.add_event("span-update", update); + } + } +} + +impl Layer for ObservationLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn enabled(&self, metadata: &Metadata<'_>, _ctx: Context<'_, S>) -> bool { + metadata.target().starts_with("goose::") + } + + fn on_new_span(&self, attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) { + let span_id = id.into_u64(); + + let parent_span_id = ctx + .span_scope(id) + .and_then(|mut scope| scope.nth(1)) + .map(|parent| parent.id().into_u64()); + + let mut visitor = JsonVisitor::new(); + attrs.record(&mut visitor); + + let span_data = SpanData { + observation_id: Uuid::new_v4().to_string(), + name: attrs.metadata().name().to_string(), + start_time: Utc::now().to_rfc3339(), + level: map_level(attrs.metadata().level()).to_owned(), + metadata: visitor.recorded_fields, + parent_span_id, + }; + + let layer = self.clone(); + tokio::spawn(async move { layer.handle_span(span_id, span_data).await }); + } + + fn on_close(&self, id: Id, _ctx: Context<'_, S>) { + let span_id = id.into_u64(); + let layer = self.clone(); + tokio::spawn(async move { layer.handle_span_close(span_id).await }); + } + + fn on_record(&self, span: &Id, values: &span::Record<'_>, _ctx: Context<'_, S>) { + let span_id = span.into_u64(); + let mut visitor = JsonVisitor::new(); + values.record(&mut visitor); + let metadata = visitor.recorded_fields; + + if !metadata.is_empty() { + let layer = self.clone(); + tokio::spawn(async move { layer.handle_record(span_id, metadata).await }); + } + } + + fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { + let mut visitor = JsonVisitor::new(); + event.record(&mut visitor); + let metadata = visitor.recorded_fields; + + if let Some(span_id) = ctx.lookup_current().map(|span| span.id().into_u64()) { + let layer = self.clone(); + tokio::spawn(async move { layer.handle_record(span_id, metadata).await }); + } + } +} + +#[derive(Debug)] +struct JsonVisitor { + recorded_fields: serde_json::Map, +} + +impl JsonVisitor { + fn new() -> Self { + Self { + recorded_fields: serde_json::Map::new(), + } + } + + fn insert_value(&mut self, field: &Field, value: Value) { + self.recorded_fields.insert(field.name().to_string(), value); + } +} + +macro_rules! record_field { + ($fn_name:ident, $type:ty) => { + fn $fn_name(&mut self, field: &Field, value: $type) { + self.insert_value(field, Value::from(value)); + } + }; +} + +impl Visit for JsonVisitor { + record_field!(record_i64, i64); + record_field!(record_u64, u64); + record_field!(record_bool, bool); + record_field!(record_str, &str); + + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + self.insert_value(field, Value::String(format!("{:?}", value))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::sync::mpsc; + use tracing::dispatcher; + + struct TestFixture { + original_subscriber: Option, + events: Option>>>, + } + + impl TestFixture { + fn new() -> Self { + Self { + original_subscriber: Some(dispatcher::get_default(dispatcher::Dispatch::clone)), + events: None, + } + } + + fn with_test_layer(mut self) -> (Self, ObservationLayer) { + let events = Arc::new(Mutex::new(Vec::new())); + let mock_manager = MockBatchManager::new(events.clone()); + + let layer = ObservationLayer { + batch_manager: Arc::new(Mutex::new(mock_manager)), + span_tracker: Arc::new(Mutex::new(SpanTracker::new())), + }; + + self.events = Some(events); + (self, layer) + } + + async fn get_events(&self) -> Vec<(String, Value)> { + self.events + .as_ref() + .expect("Events not initialized") + .lock() + .await + .clone() + } + } + + impl Drop for TestFixture { + fn drop(&mut self) { + if let Some(subscriber) = &self.original_subscriber { + let _ = dispatcher::set_global_default(subscriber.clone()); + } + } + } + + struct MockBatchManager { + events: Arc>>, + sender: mpsc::UnboundedSender<(String, Value)>, + } + + impl MockBatchManager { + fn new(events: Arc>>) -> Self { + let (sender, mut receiver) = mpsc::unbounded_channel(); + let events_clone = events.clone(); + + tokio::spawn(async move { + while let Some((event_type, body)) = receiver.recv().await { + events_clone.lock().await.push((event_type, body)); + } + }); + + Self { events, sender } + } + } + + impl BatchManager for MockBatchManager { + fn add_event(&mut self, event_type: &str, body: Value) { + self.sender + .send((event_type.to_string(), body)) + .expect("Failed to send event"); + } + + fn send(&mut self) -> Result<(), Box> { + Ok(()) + } + + fn is_empty(&self) -> bool { + futures::executor::block_on(async { self.events.lock().await.is_empty() }) + } + } + + fn create_test_span_data() -> SpanData { + SpanData { + observation_id: Uuid::new_v4().to_string(), + name: "test_span".to_string(), + start_time: Utc::now().to_rfc3339(), + level: "DEFAULT".to_string(), + metadata: serde_json::Map::new(), + parent_span_id: None, + } + } + + const TEST_WAIT_DURATION: Duration = Duration::from_secs(6); + + #[tokio::test] + async fn test_span_creation() { + let (fixture, layer) = TestFixture::new().with_test_layer(); + let span_id = 1u64; + let span_data = create_test_span_data(); + + layer.handle_span(span_id, span_data.clone()).await; + tokio::time::sleep(TEST_WAIT_DURATION).await; + + let events = fixture.get_events().await; + assert_eq!(events.len(), 2); // trace-create and observation-create + + let (event_type, body) = &events[1]; + assert_eq!(event_type, "observation-create"); + assert_eq!(body["id"], span_data.observation_id); + assert_eq!(body["name"], "test_span"); + assert_eq!(body["type"], "SPAN"); + } + + #[tokio::test] + async fn test_span_close() { + let (fixture, layer) = TestFixture::new().with_test_layer(); + let span_id = 1u64; + let span_data = create_test_span_data(); + + layer.handle_span(span_id, span_data.clone()).await; + layer.handle_span_close(span_id).await; + tokio::time::sleep(TEST_WAIT_DURATION).await; + + let events = fixture.get_events().await; + assert_eq!(events.len(), 3); // trace-create, observation-create, observation-update + + let (event_type, body) = &events[2]; + assert_eq!(event_type, "observation-update"); + assert_eq!(body["id"], span_data.observation_id); + assert!(body["endTime"].as_str().is_some()); + } + + #[tokio::test] + async fn test_record_handling() { + let (fixture, layer) = TestFixture::new().with_test_layer(); + let span_id = 1u64; + let span_data = create_test_span_data(); + + layer.handle_span(span_id, span_data.clone()).await; + + let mut metadata = serde_json::Map::new(); + metadata.insert("input".to_string(), json!("test input")); + metadata.insert("output".to_string(), json!("test output")); + metadata.insert("custom_field".to_string(), json!("custom value")); + + layer.handle_record(span_id, metadata).await; + tokio::time::sleep(TEST_WAIT_DURATION).await; + + let events = fixture.get_events().await; + assert_eq!(events.len(), 3); // trace-create, observation-create, span-update + + let (event_type, body) = &events[2]; + assert_eq!(event_type, "span-update"); + assert_eq!(body["input"], "test input"); + assert_eq!(body["output"], "test output"); + assert_eq!(body["metadata"]["custom_field"], "custom value"); + } + + #[test] + fn test_flatten_metadata() { + let _fixture = TestFixture::new(); + let mut metadata = serde_json::Map::new(); + metadata.insert("simple".to_string(), json!("value")); + metadata.insert( + "complex".to_string(), + json!({ + "text": "inner value" + }), + ); + + let flattened = flatten_metadata(metadata); + assert_eq!(flattened["simple"], "value"); + assert_eq!(flattened["complex"], "inner value"); + } +} diff --git a/crates/goose/src/truncate.rs b/crates/goose/src/truncate.rs new file mode 100644 index 00000000..d8375689 --- /dev/null +++ b/crates/goose/src/truncate.rs @@ -0,0 +1,467 @@ +use crate::message::Message; +use anyhow::{anyhow, Result}; +use mcp_core::Role; +use std::collections::HashSet; +use tracing::debug; + +/// Trait representing a truncation strategy +pub trait TruncationStrategy { + /// Determines the indices of messages to remove to fit within the context limit. + /// + /// - `messages`: The list of messages in the conversation. + /// - `token_counts`: A parallel array containing the token count for each message. + /// - `context_limit`: The maximum allowed context length in tokens. + /// + /// Returns a vector of indices to remove. + fn determine_indices_to_remove( + &self, + messages: &[Message], + token_counts: &[usize], + context_limit: usize, + ) -> Result>; +} + +/// Strategy to truncate messages by removing the oldest first +pub struct OldestFirstTruncation; + +impl TruncationStrategy for OldestFirstTruncation { + fn determine_indices_to_remove( + &self, + messages: &[Message], + token_counts: &[usize], + context_limit: usize, + ) -> Result> { + let mut indices_to_remove = HashSet::new(); + let mut total_tokens: usize = token_counts.iter().sum(); + let mut tool_ids_to_remove = HashSet::new(); + + for (i, message) in messages.iter().enumerate() { + if total_tokens <= context_limit { + break; + } + + // Remove the message + indices_to_remove.insert(i); + total_tokens -= token_counts[i]; + debug!( + "OldestFirst: Removing message at index {}. Tokens removed: {}", + i, token_counts[i] + ); + + // If it's a ToolRequest or ToolResponse, mark its pair for removal + if message.is_tool_call() || message.is_tool_response() { + message.get_tool_ids().iter().for_each(|id| { + tool_ids_to_remove.insert((i, id.to_string())); + }); + } + } + + // Now, find and remove paired ToolResponses or ToolRequests + for (i, message) in messages.iter().enumerate() { + let message_tool_ids = message.get_tool_ids(); + // Find the other part of the pair - same tool_id but different message index + for (message_idx, tool_id) in &tool_ids_to_remove { + if message_idx != &i && message_tool_ids.contains(tool_id.as_str()) { + indices_to_remove.insert(i); + total_tokens -= token_counts[i]; + // No need to check other tool_ids for this message since it's already marked + break; + } + } + } + + Ok(indices_to_remove) + } +} + +/// Truncates the messages to fit within the model's context window. +/// Mutates the input messages and token counts in place. +/// Returns an error if it's impossible to truncate the messages within the context limit. +/// - messages: The vector of messages in the conversation. +/// - token_counts: A parallel vector containing the token count for each message. +/// - context_limit: The maximum allowed context length in tokens. +/// - strategy: The truncation strategy to use. Only option is OldestFirstTruncation. +pub fn truncate_messages( + messages: &mut Vec, + token_counts: &mut Vec, + context_limit: usize, + strategy: &dyn TruncationStrategy, +) -> Result<()> { + if messages.len() != token_counts.len() { + return Err(anyhow!( + "The vector for messages and token_counts must have same length" + )); + } + + // Step 1: Calculate total tokens + let mut total_tokens: usize = token_counts.iter().sum(); + debug!("Total tokens before truncation: {}", total_tokens); + + // Check if any individual message is larger than the context limit + let min_user_msg_tokens = messages + .iter() + .zip(token_counts.iter()) + .filter(|(msg, _)| msg.role == Role::User && msg.has_only_text_content()) + .map(|(_, &tokens)| tokens) + .min(); + + // If there are no valid user messages, or the smallest one is too big for the context + if min_user_msg_tokens.is_none() || min_user_msg_tokens.unwrap() > context_limit { + return Err(anyhow!( + "Not possible to truncate messages within context limit" + )); + } + + if total_tokens <= context_limit { + return Ok(()); // No truncation needed + } + + // Step 2: Determine indices to remove based on strategy + let indices_to_remove = + strategy.determine_indices_to_remove(messages, token_counts, context_limit)?; + + // Step 3: Remove the marked messages + // Vectorize the set and sort in reverse order to avoid shifting indices when removing + let mut indices_to_remove = indices_to_remove.iter().cloned().collect::>(); + indices_to_remove.sort_unstable_by(|a, b| b.cmp(a)); + + for &index in &indices_to_remove { + if index < messages.len() { + let _ = messages.remove(index); + let removed_tokens = token_counts.remove(index); + total_tokens -= removed_tokens; + } + } + + // Step 4: Ensure the last message is a user message with TextContent only + while let Some(last_msg) = messages.last() { + if last_msg.role != Role::User || !last_msg.has_only_text_content() { + let _ = messages.pop().ok_or(anyhow!("Failed to pop message"))?; + let removed_tokens = token_counts + .pop() + .ok_or(anyhow!("Failed to pop token count"))?; + total_tokens -= removed_tokens; + } else { + break; + } + } + + // Step 5: Check first msg is a User message with TextContent only + while let Some(first_msg) = messages.first() { + if first_msg.role != Role::User || !first_msg.has_only_text_content() { + let _ = messages.remove(0); + let removed_tokens = token_counts.remove(0); + total_tokens -= removed_tokens; + } else { + break; + } + } + + debug!("Total tokens after truncation: {}", total_tokens); + + // Ensure we have at least one message remaining and it's within context limit + if messages.is_empty() { + return Err(anyhow!( + "Unable to preserve any messages within context limit" + )); + } + + if total_tokens > context_limit { + return Err(anyhow!( + "Unable to truncate messages within context window." + )); + } + + debug!("Truncation complete. Total tokens: {}", total_tokens); + Ok(()) +} + +// truncate.rs + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::Message; + use anyhow::Result; + use mcp_core::content::Content; + use mcp_core::tool::ToolCall; + use serde_json::json; + + // Helper function to create a user text message with a specified token count + fn user_text(index: usize, tokens: usize) -> (Message, usize) { + let content = format!("User message {}", index); + (Message::user().with_text(content), tokens) + } + + // Helper function to create an assistant text message with a specified token count + fn assistant_text(index: usize, tokens: usize) -> (Message, usize) { + let content = format!("Assistant message {}", index); + (Message::assistant().with_text(content), tokens) + } + + // Helper function to create a tool request message with a specified token count + fn assistant_tool_request(id: &str, tool_call: ToolCall, tokens: usize) -> (Message, usize) { + ( + Message::assistant().with_tool_request(id, Ok(tool_call)), + tokens, + ) + } + + // Helper function to create a tool response message with a specified token count + fn user_tool_response(id: &str, result: Vec, tokens: usize) -> (Message, usize) { + (Message::user().with_tool_response(id, Ok(result)), tokens) + } + + // Helper function to create messages with alternating user and assistant + // text messages of a fixed token count + fn create_messages_with_counts( + num_pairs: usize, + tokens: usize, + remove_last: bool, + ) -> (Vec, Vec) { + let mut messages: Vec = (0..num_pairs) + .flat_map(|i| { + vec![ + user_text(i * 2, tokens).0, + assistant_text((i * 2) + 1, tokens).0, + ] + }) + .collect(); + + if remove_last { + messages.pop(); + } + + let token_counts = vec![tokens; messages.len()]; + + (messages, token_counts) + } + + #[test] + fn test_oldest_first_no_truncation() -> Result<()> { + let (messages, token_counts) = create_messages_with_counts(1, 10, false); + let context_limit = 25; + + let mut messages_clone = messages.clone(); + let mut token_counts_clone = token_counts.clone(); + truncate_messages( + &mut messages_clone, + &mut token_counts_clone, + context_limit, + &OldestFirstTruncation, + )?; + + assert_eq!(messages_clone, messages); + assert_eq!(token_counts_clone, token_counts); + Ok(()) + } + + #[test] + fn test_complex_conversation_with_tools() -> Result<()> { + // Simulating a real conversation with multiple tool interactions + let tool_call1 = ToolCall::new("file_read", json!({"path": "/tmp/test.txt"})); + let tool_call2 = ToolCall::new("database_query", json!({"query": "SELECT * FROM users"})); + + let messages = vec![ + user_text(1, 15).0, // Initial user query + assistant_tool_request("tool1", tool_call1.clone(), 20).0, + user_tool_response( + "tool1", + vec![Content::text("File contents".to_string())], + 10, + ) + .0, + assistant_text(2, 25).0, // Assistant processes file contents + user_text(3, 10).0, // User follow-up + assistant_tool_request("tool2", tool_call2.clone(), 30).0, + user_tool_response( + "tool2", + vec![Content::text("Query results".to_string())], + 20, + ) + .0, + assistant_text(4, 35).0, // Assistant analyzes query results + user_text(5, 5).0, // Final user confirmation + ]; + + let token_counts = vec![15, 20, 10, 25, 10, 30, 20, 35, 5]; + let context_limit = 100; // Force truncation while preserving some tool interactions + + let mut messages_clone = messages.clone(); + let mut token_counts_clone = token_counts.clone(); + truncate_messages( + &mut messages_clone, + &mut token_counts_clone, + context_limit, + &OldestFirstTruncation, + )?; + + // Verify that tool pairs are kept together and the conversation remains coherent + assert!(messages_clone.len() >= 3); // At least one complete interaction should remain + assert!(messages_clone.last().unwrap().role == Role::User); // Last message should be from user + + // Verify tool pairs are either both present or both removed + let tool_ids: HashSet<_> = messages_clone + .iter() + .flat_map(|m| m.get_tool_ids()) + .collect(); + + // Each tool ID should appear 0 or 2 times (request + response) + for id in tool_ids { + let count = messages_clone + .iter() + .flat_map(|m| m.get_tool_ids().into_iter()) + .filter(|&tool_id| tool_id == id) + .count(); + assert!(count == 0 || count == 2, "Tool pair was split: {}", id); + } + + Ok(()) + } + + #[test] + fn test_edge_case_context_window() -> Result<()> { + // Test case where we're exactly at the context limit + let (mut messages, mut token_counts) = create_messages_with_counts(2, 25, false); + let context_limit = 100; // Exactly matches total tokens + + truncate_messages( + &mut messages, + &mut token_counts, + context_limit, + &OldestFirstTruncation, + )?; + + assert_eq!(messages.len(), 4); // No truncation needed + assert_eq!(token_counts.iter().sum::(), 100); + + // Now add one more token to force truncation + messages.push(user_text(5, 1).0); + token_counts.push(1); + + truncate_messages( + &mut messages, + &mut token_counts, + context_limit, + &OldestFirstTruncation, + )?; + + assert!(token_counts.iter().sum::() <= context_limit); + assert!(messages.last().unwrap().role == Role::User); + + Ok(()) + } + + #[test] + fn test_multi_tool_chain() -> Result<()> { + // Simulate a chain of dependent tool calls + let tool_calls = vec![ + ToolCall::new("git_status", json!({})), + ToolCall::new("git_diff", json!({"file": "main.rs"})), + ToolCall::new("git_commit", json!({"message": "Update"})), + ]; + + let mut messages = Vec::new(); + let mut token_counts = Vec::new(); + + // Build a chain of related tool calls + // 30 tokens each round + for (i, tool_call) in tool_calls.into_iter().enumerate() { + let id = format!("git_{}", i); + messages.push(user_text(i, 10).0); + token_counts.push(10); + + messages.push(assistant_tool_request(&id, tool_call, 15).0); + token_counts.push(20); + } + + let context_limit = 50; // Force partial truncation + let mut messages_clone = messages.clone(); + let mut token_counts_clone = token_counts.clone(); + + truncate_messages( + &mut messages_clone, + &mut token_counts_clone, + context_limit, + &OldestFirstTruncation, + )?; + + // Verify that remaining tool chains are complete + let remaining_tool_ids: HashSet<_> = messages_clone + .iter() + .flat_map(|m| m.get_tool_ids()) + .collect(); + + for _id in remaining_tool_ids { + // Count request/response pairs + let requests = messages_clone + .iter() + .flat_map(|m| m.get_tool_request_ids().into_iter()) + .count(); + + let responses = messages_clone + .iter() + .flat_map(|m| m.get_tool_response_ids().into_iter()) + .count(); + + assert_eq!(requests, 1, "Each remaining tool should have one request"); + assert_eq!(responses, 1, "Each remaining tool should have one response"); + } + + Ok(()) + } + + #[test] + fn test_truncation_with_image_content() -> Result<()> { + // Create a conversation with image content mixed in + let mut messages = vec![ + Message::user().with_image("base64_data", "image/png"), // 50 tokens + Message::assistant().with_text("I see the image"), // 10 tokens + Message::user().with_text("Can you describe it?"), // 10 tokens + Message::assistant().with_text("It shows..."), // 20 tokens + Message::user().with_text("Thanks!"), // 5 tokens + ]; + let mut token_counts = vec![50, 10, 10, 20, 5]; + let context_limit = 45; // Force truncation + + truncate_messages( + &mut messages, + &mut token_counts, + context_limit, + &OldestFirstTruncation, + )?; + + // Verify the conversation still makes sense + assert!(messages.len() >= 1); + assert!(messages.last().unwrap().role == Role::User); + assert!(token_counts.iter().sum::() <= context_limit); + + Ok(()) + } + + #[test] + fn test_error_cases() -> Result<()> { + // Test impossibly small context window + let (mut messages, mut token_counts) = create_messages_with_counts(1, 10, false); + let result = truncate_messages( + &mut messages, + &mut token_counts, + 5, // Impossibly small context + &OldestFirstTruncation, + ); + assert!(result.is_err()); + + // Test unmatched token counts + let mut messages = vec![user_text(1, 10).0]; + let mut token_counts = vec![10, 10]; // Mismatched length + let result = truncate_messages( + &mut messages, + &mut token_counts, + 100, + &OldestFirstTruncation, + ); + assert!(result.is_err()); + + Ok(()) + } +} diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs new file mode 100644 index 00000000..03b3ccef --- /dev/null +++ b/crates/goose/tests/providers.rs @@ -0,0 +1,431 @@ +use anyhow::Result; +use dotenv::dotenv; +use goose::message::{Message, MessageContent}; +use goose::providers::base::Provider; +use goose::providers::errors::ProviderError; +use goose::providers::{anthropic, databricks, google, groq, ollama, openai, openrouter}; +use mcp_core::content::Content; +use mcp_core::tool::Tool; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; + +#[derive(Debug, Clone, Copy)] +enum TestStatus { + Passed, + Skipped, + Failed, +} + +impl std::fmt::Display for TestStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TestStatus::Passed => write!(f, "βœ…"), + TestStatus::Skipped => write!(f, "⏭️"), + TestStatus::Failed => write!(f, "❌"), + } + } +} + +struct TestReport { + results: Mutex>, +} + +impl TestReport { + fn new() -> Arc { + Arc::new(Self { + results: Mutex::new(HashMap::new()), + }) + } + + fn record_status(&self, provider: &str, status: TestStatus) { + let mut results = self.results.lock().unwrap(); + results.insert(provider.to_string(), status); + } + + fn record_pass(&self, provider: &str) { + self.record_status(provider, TestStatus::Passed); + } + + fn record_skip(&self, provider: &str) { + self.record_status(provider, TestStatus::Skipped); + } + + fn record_fail(&self, provider: &str) { + self.record_status(provider, TestStatus::Failed); + } + + fn print_summary(&self) { + println!("\n============== Providers =============="); + let results = self.results.lock().unwrap(); + let mut providers: Vec<_> = results.iter().collect(); + providers.sort_by(|a, b| a.0.cmp(b.0)); + + for (provider, status) in providers { + println!("{} {}", status, provider); + } + println!("=======================================\n"); + } +} + +lazy_static::lazy_static! { + static ref TEST_REPORT: Arc = TestReport::new(); + static ref ENV_LOCK: Mutex<()> = Mutex::new(()); +} + +/// Generic test harness for any Provider implementation +struct ProviderTester { + provider: Box, + name: String, +} + +impl ProviderTester { + fn new(provider: T, name: String) -> Self { + Self { + provider: Box::new(provider), + name, + } + } + + async fn test_basic_response(&self) -> Result<()> { + let message = Message::user().with_text("Just say hello!"); + + let (response, _) = self + .provider + .complete("You are a helpful assistant.", &[message], &[]) + .await?; + + // For a basic response, we expect a single text response + assert_eq!( + response.content.len(), + 1, + "Expected single content item in response" + ); + + // Verify we got a text response + assert!( + matches!(response.content[0], MessageContent::Text(_)), + "Expected text response" + ); + + Ok(()) + } + + async fn test_tool_usage(&self) -> Result<()> { + let weather_tool = Tool::new( + "get_weather", + "Get the weather for a location", + serde_json::json!({ + "type": "object", + "required": ["location"], + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + } + } + }), + ); + + let message = Message::user().with_text("What's the weather like in San Francisco?"); + + let (response1, _) = self + .provider + .complete( + "You are a helpful weather assistant.", + &[message.clone()], + &[weather_tool.clone()], + ) + .await?; + + println!("=== {}::reponse1 ===", self.name); + dbg!(&response1); + println!("==================="); + + // Verify we got a tool request + assert!( + response1 + .content + .iter() + .any(|content| matches!(content, MessageContent::ToolRequest(_))), + "Expected tool request in response" + ); + + let id = &response1 + .content + .iter() + .filter_map(|message| message.as_tool_request()) + .last() + .expect("got tool request") + .id; + + let weather = Message::user().with_tool_response( + id, + Ok(vec![Content::text( + " + 50Β°FΒ°C + Precipitation: 0% + Humidity: 84% + Wind: 2 mph + Weather + Saturday 9:00 PM + Clear", + )]), + ); + + // Verify we construct a valid payload including the request/response pair for the next inference + let (response2, _) = self + .provider + .complete( + "You are a helpful weather assistant.", + &[message, response1, weather], + &[weather_tool], + ) + .await?; + + println!("=== {}::reponse2 ===", self.name); + dbg!(&response2); + println!("==================="); + + assert!( + response2 + .content + .iter() + .any(|content| matches!(content, MessageContent::Text(_))), + "Expected text for final response" + ); + + Ok(()) + } + + async fn test_context_length_exceeded_error(&self) -> Result<()> { + // Google Gemini has a really long context window + let large_message_content = if self.name.to_lowercase() == "google" { + "hello ".repeat(1_300_000) + } else { + "hello ".repeat(300_000) + }; + + let messages = vec![ + Message::user().with_text("hi there. what is 2 + 2?"), + Message::assistant().with_text("hey! I think it's 4."), + Message::user().with_text(&large_message_content), + Message::assistant().with_text("heyy!!"), + // Messages before this mark should be truncated + Message::user().with_text("what's the meaning of life?"), + Message::assistant().with_text("the meaning of life is 42"), + Message::user().with_text( + "did I ask you what's 2+2 in this message history? just respond with 'yes' or 'no'", + ), + ]; + + // Test that we get ProviderError::ContextLengthExceeded when the context window is exceeded + let result = self + .provider + .complete("You are a helpful assistant.", &messages, &[]) + .await; + + // Print some debug info + println!("=== {}::context_length_exceeded_error ===", self.name); + dbg!(&result); + println!("==================="); + + assert!( + result.is_err(), + "Expected error when context window is exceeded" + ); + assert!( + matches!(result.unwrap_err(), ProviderError::ContextLengthExceeded(_)), + "Expected error to be ContextLengthExceeded" + ); + + Ok(()) + } + + /// Run all provider tests + async fn run_test_suite(&self) -> Result<()> { + self.test_basic_response().await?; + self.test_tool_usage().await?; + self.test_context_length_exceeded_error().await?; + Ok(()) + } +} + +fn load_env() { + if let Ok(path) = dotenv() { + println!("Loaded environment from {:?}", path); + } +} + +/// Helper function to run a provider test with proper error handling and reporting +async fn test_provider( + name: &str, + required_vars: &[&str], + env_modifications: Option>>, + provider_fn: F, +) -> Result<()> +where + F: FnOnce() -> T, + T: Provider + Send + Sync + 'static, +{ + // We start off as failed, so that if the process panics it is seen as a failure + TEST_REPORT.record_fail(name); + + // Take exclusive access to environment modifications + let lock = ENV_LOCK.lock().unwrap(); + + load_env(); + + // Save current environment state for required vars and modified vars + let mut original_env = HashMap::new(); + for &var in required_vars { + if let Ok(val) = std::env::var(var) { + original_env.insert(var, val); + } + } + if let Some(mods) = &env_modifications { + for &var in mods.keys() { + if let Ok(val) = std::env::var(var) { + original_env.insert(var, val); + } + } + } + + // Apply any environment modifications + if let Some(mods) = &env_modifications { + for (&var, value) in mods.iter() { + match value { + Some(val) => std::env::set_var(var, val), + None => std::env::remove_var(var), + } + } + } + + // Setup the provider + let missing_vars = required_vars.iter().any(|var| std::env::var(var).is_err()); + if missing_vars { + println!("Skipping {} tests - credentials not configured", name); + TEST_REPORT.record_skip(name); + return Ok(()); + } + + let provider = provider_fn(); + + // Restore original environment + for (&var, value) in original_env.iter() { + std::env::set_var(var, value); + } + if let Some(mods) = env_modifications { + for &var in mods.keys() { + if !original_env.contains_key(var) { + std::env::remove_var(var); + } + } + } + + std::mem::drop(lock); + + let tester = ProviderTester::new(provider, name.to_string()); + match tester.run_test_suite().await { + Ok(_) => { + TEST_REPORT.record_pass(name); + Ok(()) + } + Err(e) => { + println!("{} test failed: {}", name, e); + TEST_REPORT.record_fail(name); + Err(e) + } + } +} + +#[tokio::test] +async fn test_openai_provider() -> Result<()> { + test_provider( + "OpenAI", + &["OPENAI_API_KEY"], + None, + openai::OpenAiProvider::default, + ) + .await +} + +#[tokio::test] +async fn test_databricks_provider() -> Result<()> { + test_provider( + "Databricks", + &["DATABRICKS_HOST", "DATABRICKS_TOKEN"], + None, + databricks::DatabricksProvider::default, + ) + .await +} + +#[tokio::test] +async fn test_databricks_provider_oauth() -> Result<()> { + let mut env_mods = HashMap::new(); + env_mods.insert("DATABRICKS_TOKEN", None); + + test_provider( + "Databricks OAuth", + &["DATABRICKS_HOST"], + Some(env_mods), + databricks::DatabricksProvider::default, + ) + .await +} + +#[tokio::test] +async fn test_ollama_provider() -> Result<()> { + test_provider( + "Ollama", + &["OLLAMA_HOST"], + None, + ollama::OllamaProvider::default, + ) + .await +} + +#[tokio::test] +async fn test_groq_provider() -> Result<()> { + test_provider("Groq", &["GROQ_API_KEY"], None, groq::GroqProvider::default).await +} + +#[tokio::test] +async fn test_anthropic_provider() -> Result<()> { + test_provider( + "Anthropic", + &["ANTHROPIC_API_KEY"], + None, + anthropic::AnthropicProvider::default, + ) + .await +} + +#[tokio::test] +async fn test_openrouter_provider() -> Result<()> { + test_provider( + "OpenRouter", + &["OPENROUTER_API_KEY"], + None, + openrouter::OpenRouterProvider::default, + ) + .await +} + +#[tokio::test] +async fn test_google_provider() -> Result<()> { + test_provider( + "Google", + &["GOOGLE_API_KEY"], + None, + google::GoogleProvider::default, + ) + .await +} + +// Print the final test report +#[ctor::dtor] +fn print_test_report() { + TEST_REPORT.print_summary(); +} diff --git a/crates/goose/tests/truncate_agent.rs b/crates/goose/tests/truncate_agent.rs new file mode 100644 index 00000000..991bba79 --- /dev/null +++ b/crates/goose/tests/truncate_agent.rs @@ -0,0 +1,254 @@ +// src/lib.rs or tests/truncate_agent_tests.rs + +use anyhow::Result; +use futures::StreamExt; +use goose::agents::AgentFactory; +use goose::message::Message; +use goose::model::ModelConfig; +use goose::providers::base::Provider; +use goose::providers::{anthropic::AnthropicProvider, databricks::DatabricksProvider}; +use goose::providers::{google::GoogleProvider, groq::GroqProvider}; +use goose::providers::{ + ollama::OllamaProvider, openai::OpenAiProvider, openrouter::OpenRouterProvider, +}; + +#[derive(Debug)] +enum ProviderType { + OpenAi, + Anthropic, + Databricks, + Google, + Groq, + Ollama, + OpenRouter, +} + +impl ProviderType { + fn required_env(&self) -> &'static [&'static str] { + match self { + ProviderType::OpenAi => &["OPENAI_API_KEY"], + ProviderType::Anthropic => &["ANTHROPIC_API_KEY"], + ProviderType::Databricks => &["DATABRICKS_HOST"], + ProviderType::Google => &["GOOGLE_API_KEY"], + ProviderType::Groq => &["GROQ_API_KEY"], + ProviderType::Ollama => &[], + ProviderType::OpenRouter => &["OPENROUTER_API_KEY"], + } + } + + fn pre_check(&self) -> Result<()> { + match self { + ProviderType::Ollama => { + // Check if the `ollama ls` CLI command works + use std::process::Command; + let output = Command::new("ollama").arg("ls").output(); + if let Ok(output) = output { + if output.status.success() { + return Ok(()); // CLI is running + } + } + println!("Skipping Ollama tests - `ollama ls` command not found or failed"); + Err(anyhow::anyhow!("Ollama CLI is not running")) + } + _ => Ok(()), // Other providers don't need special pre-checks + } + } + + fn create_provider(&self, model_config: ModelConfig) -> Result> { + Ok(match self { + ProviderType::OpenAi => Box::new(OpenAiProvider::from_env(model_config)?), + ProviderType::Anthropic => Box::new(AnthropicProvider::from_env(model_config)?), + ProviderType::Databricks => Box::new(DatabricksProvider::from_env(model_config)?), + ProviderType::Google => Box::new(GoogleProvider::from_env(model_config)?), + ProviderType::Groq => Box::new(GroqProvider::from_env(model_config)?), + ProviderType::Ollama => Box::new(OllamaProvider::from_env(model_config)?), + ProviderType::OpenRouter => Box::new(OpenRouterProvider::from_env(model_config)?), + }) + } +} + +pub fn check_required_env_vars(required_vars: &[&str]) -> Result<()> { + let missing_vars: Vec<&str> = required_vars + .iter() + .filter(|&&var| std::env::var(var).is_err()) + .cloned() + .collect(); + + if !missing_vars.is_empty() { + println!( + "Skipping tests. Missing environment variables: {:?}", + missing_vars + ); + return Err(anyhow::anyhow!("Required environment variables not set")); + } + Ok(()) +} + +async fn run_truncate_test( + provider_type: ProviderType, + model: &str, + context_window: usize, +) -> Result<()> { + let model_config = ModelConfig::new(model.to_string()) + .with_context_limit(Some(context_window)) + .with_temperature(Some(0.0)); + let provider = provider_type.create_provider(model_config)?; + + let agent = AgentFactory::create("truncate", provider).unwrap(); + let repeat_count = context_window + 10_000; + let large_message_content = "hello ".repeat(repeat_count); + let messages = vec![ + Message::user().with_text("hi there. what is 2 + 2?"), + Message::assistant().with_text("hey! I think it's 4."), + Message::user().with_text(&large_message_content), + Message::assistant().with_text("heyy!!"), + Message::user().with_text("what's the meaning of life?"), + Message::assistant().with_text("the meaning of life is 42"), + Message::user().with_text( + "did I ask you what's 2+2 in this message history? just respond with 'yes' or 'no'", + ), + ]; + + let reply_stream = agent.reply(&messages).await?; + tokio::pin!(reply_stream); + + let mut responses = Vec::new(); + while let Some(response_result) = reply_stream.next().await { + match response_result { + Ok(response) => responses.push(response), + Err(e) => { + println!("Error: {:?}", e); + return Err(e); + } + } + } + + println!("Responses: {responses:?}\n"); + assert_eq!(responses.len(), 1); + assert_eq!(responses[0].content.len(), 1); + + let response_text = responses[0].content[0].as_text().unwrap(); + assert!(response_text.to_lowercase().contains("no")); + assert!(!response_text.to_lowercase().contains("yes")); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct TestConfig { + provider_type: ProviderType, + model: &'static str, + context_window: usize, + } + + async fn run_test_with_config(config: TestConfig) -> Result<()> { + println!("Starting test for {config:?}"); + + // Check for required environment variables + if check_required_env_vars(config.provider_type.required_env()).is_err() { + return Ok(()); // Skip test if env vars are missing + } + + // Run provider-specific pre-checks + if config.provider_type.pre_check().is_err() { + return Ok(()); // Skip test if pre-check fails + } + + // Run the truncate test + run_truncate_test(config.provider_type, config.model, config.context_window).await + } + + #[tokio::test] + async fn test_truncate_agent_with_openai() -> Result<()> { + run_test_with_config(TestConfig { + provider_type: ProviderType::OpenAi, + model: "gpt-4o-mini", + context_window: 128_000, + }) + .await + } + + #[tokio::test] + async fn test_truncate_agent_with_anthropic() -> Result<()> { + run_test_with_config(TestConfig { + provider_type: ProviderType::Anthropic, + model: "claude-3-5-haiku-latest", + context_window: 200_000, + }) + .await + } + + #[tokio::test] + async fn test_truncate_agent_with_databricks() -> Result<()> { + run_test_with_config(TestConfig { + provider_type: ProviderType::Databricks, + model: "databricks-meta-llama-3-3-70b-instruct", + context_window: 128_000, + }) + .await + } + + #[tokio::test] + async fn test_truncate_agent_with_databricks_bedrock() -> Result<()> { + run_test_with_config(TestConfig { + provider_type: ProviderType::Databricks, + model: "claude-3-5-sonnet-2", + context_window: 200_000, + }) + .await + } + + #[tokio::test] + async fn test_truncate_agent_with_databricks_openai() -> Result<()> { + run_test_with_config(TestConfig { + provider_type: ProviderType::Databricks, + model: "gpt-4o-mini", + context_window: 128_000, + }) + .await + } + + #[tokio::test] + async fn test_truncate_agent_with_google() -> Result<()> { + run_test_with_config(TestConfig { + provider_type: ProviderType::Google, + model: "gemini-2.0-flash-exp", + context_window: 1_200_000, + }) + .await + } + + #[tokio::test] + async fn test_truncate_agent_with_groq() -> Result<()> { + run_test_with_config(TestConfig { + provider_type: ProviderType::Groq, + model: "gemma2-9b-it", + context_window: 9_000, + }) + .await + } + + #[tokio::test] + async fn test_truncate_agent_with_openrouter() -> Result<()> { + run_test_with_config(TestConfig { + provider_type: ProviderType::OpenRouter, + model: "deepseek/deepseek-r1", + context_window: 130_000, + }) + .await + } + + #[tokio::test] + async fn test_truncate_agent_with_ollama() -> Result<()> { + run_test_with_config(TestConfig { + provider_type: ProviderType::Ollama, + model: "llama3.2", + context_window: 128_000, + }) + .await + } +} diff --git a/crates/mcp-client/Cargo.toml b/crates/mcp-client/Cargo.toml new file mode 100644 index 00000000..ab4ca787 --- /dev/null +++ b/crates/mcp-client/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "mcp-client" +version = "0.1.0" +edition = "2021" + +[dependencies] +mcp-core = { path = "../mcp-core" } +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.11", default-features = false, features = ["json", "stream", "rustls-tls"] } +eventsource-client = "0.12.0" +futures = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +async-trait = "0.1.83" +url = "2.5.4" +thiserror = "1.0" +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tower = { version = "0.4", features = ["timeout", "util"] } +tower-service = "0.3" +rand = "0.8" + +[dev-dependencies] diff --git a/crates/mcp-client/README.md b/crates/mcp-client/README.md new file mode 100644 index 00000000..a43c4c21 --- /dev/null +++ b/crates/mcp-client/README.md @@ -0,0 +1,11 @@ +## Testing stdio transport + +```bash +cargo run -p mcp-client --example stdio +``` + +## Testing SSE transport + +1. Start the MCP server in one terminal: `fastmcp run -t sse echo.py` +2. Run the client example in new terminal: `cargo run -p mcp-client --example sse` + diff --git a/crates/mcp-client/examples/clients.rs b/crates/mcp-client/examples/clients.rs new file mode 100644 index 00000000..4913b952 --- /dev/null +++ b/crates/mcp-client/examples/clients.rs @@ -0,0 +1,131 @@ +use mcp_client::{ + client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait}, + transport::{SseTransport, StdioTransport, Transport}, + McpService, +}; +use rand::Rng; +use rand::SeedableRng; +use std::time::Duration; +use std::{collections::HashMap, sync::Arc}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::from_default_env().add_directive("mcp_client=debug".parse().unwrap()), + ) + .init(); + + let transport1 = StdioTransport::new("uvx", vec!["mcp-server-git".to_string()], HashMap::new()); + let handle1 = transport1.start().await?; + let service1 = McpService::with_timeout(handle1, Duration::from_secs(30)); + let client1 = McpClient::new(service1); + + let transport2 = StdioTransport::new("uvx", vec!["mcp-server-git".to_string()], HashMap::new()); + let handle2 = transport2.start().await?; + let service2 = McpService::with_timeout(handle2, Duration::from_secs(30)); + let client2 = McpClient::new(service2); + + let transport3 = SseTransport::new("http://localhost:8000/sse", HashMap::new()); + let handle3 = transport3.start().await?; + let service3 = McpService::with_timeout(handle3, Duration::from_secs(10)); + let client3 = McpClient::new(service3); + + // Initialize both clients + let mut clients: Vec> = + vec![Box::new(client1), Box::new(client2), Box::new(client3)]; + + // Initialize all clients + for (i, client) in clients.iter_mut().enumerate() { + let info = ClientInfo { + name: format!("example-client-{}", i + 1), + version: "1.0.0".to_string(), + }; + let capabilities = ClientCapabilities::default(); + + println!("\nInitializing client {}", i + 1); + let init_result = client.initialize(info, capabilities).await?; + println!("Client {} initialized: {:?}", i + 1, init_result); + } + + // List tools for all clients + for (i, client) in clients.iter_mut().enumerate() { + let tools = client.list_tools(None).await?; + println!("\nClient {} tools: {:?}", i + 1, tools); + } + + println!("\n\n----------------------------------\n\n"); + + // Wrap clients in Arc before spawning tasks + let clients = Arc::new(clients); + let mut handles = vec![]; + + for i in 0..20 { + let clients = Arc::clone(&clients); + let handle = tokio::spawn(async move { + // let mut rng = rand::thread_rng(); + let mut rng = rand::rngs::StdRng::from_entropy(); + tokio::time::sleep(Duration::from_millis(rng.gen_range(5..50))).await; + + // Randomly select an operation + match rng.gen_range(0..4) { + 0 => { + println!("\n{i}: Listing tools for client 1 (stdio)"); + match clients[0].list_tools(None).await { + Ok(tools) => { + println!(" {i}: -> Got tools, first one: {:?}", tools.tools.first()) + } + Err(e) => println!(" {i}: -> Error: {}", e), + } + } + 1 => { + println!("\n{i}: Calling tool for client 2 (stdio)"); + match clients[1] + .call_tool("git_status", serde_json::json!({ "repo_path": "." })) + .await + { + Ok(result) => println!( + " {i}: -> Tool execution result, is_error: {:?}", + result.is_error + ), + Err(e) => println!(" {i}: -> Error: {}", e), + } + } + 2 => { + println!("\n{i}: Listing tools for client 3 (sse)"); + match clients[2].list_tools(None).await { + Ok(tools) => { + println!(" {i}: -> Got tools, first one: {:?}", tools.tools.first()) + } + Err(e) => println!(" {i}: -> Error: {}", e), + } + } + 3 => { + println!("\n{i}: Calling tool for client 3 (sse)"); + match clients[2] + .call_tool( + "echo_tool", + serde_json::json!({ "message": "Client with SSE transport - calling a tool" }), + ) + .await + { + Ok(result) => println!(" {i}: -> Tool execution result, is_error: {:?}", result.is_error), + Err(e) => println!(" {i}: -> Error: {}", e), + } + } + _ => unreachable!(), + } + Ok::<(), Box>(()) + }); + handles.push(handle); + } + + // Wait for all tasks to complete + for handle in handles { + handle.await.unwrap().unwrap(); + } + + Ok(()) +} diff --git a/crates/mcp-client/examples/sse.rs b/crates/mcp-client/examples/sse.rs new file mode 100644 index 00000000..8b93b372 --- /dev/null +++ b/crates/mcp-client/examples/sse.rs @@ -0,0 +1,70 @@ +use anyhow::Result; +use mcp_client::client::{ClientCapabilities, ClientInfo, McpClient, McpClientTrait}; +use mcp_client::transport::{SseTransport, Transport}; +use mcp_client::McpService; +use std::collections::HashMap; +use std::time::Duration; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::from_default_env() + .add_directive("mcp_client=debug".parse().unwrap()) + .add_directive("eventsource_client=info".parse().unwrap()), + ) + .init(); + + // Create the base transport + let transport = SseTransport::new("http://localhost:8000/sse", HashMap::new()); + + // Start transport + let handle = transport.start().await?; + + // Create the service with timeout middleware + let service = McpService::with_timeout(handle, Duration::from_secs(3)); + + // Create client + let mut client = McpClient::new(service); + println!("Client created\n"); + + // Initialize + let server_info = client + .initialize( + ClientInfo { + name: "test-client".into(), + version: "1.0.0".into(), + }, + ClientCapabilities::default(), + ) + .await?; + println!("Connected to server: {server_info:?}\n"); + + // Sleep for 100ms to allow the server to start - surprisingly this is required! + tokio::time::sleep(Duration::from_millis(100)).await; + + // List tools + let tools = client.list_tools(None).await?; + println!("Available tools: {tools:?}\n"); + + // Call tool + let tool_result = client + .call_tool( + "echo_tool", + serde_json::json!({ "message": "Client with SSE transport - calling a tool" }), + ) + .await?; + println!("Tool result: {tool_result:?}\n"); + + // List resources + let resources = client.list_resources(None).await?; + println!("Resources: {resources:?}\n"); + + // Read resource + let resource = client.read_resource("echo://fixedresource").await?; + println!("Resource: {resource:?}\n"); + + Ok(()) +} diff --git a/crates/mcp-client/examples/stdio.rs b/crates/mcp-client/examples/stdio.rs new file mode 100644 index 00000000..e43f036c --- /dev/null +++ b/crates/mcp-client/examples/stdio.rs @@ -0,0 +1,61 @@ +use std::collections::HashMap; + +use anyhow::Result; +use mcp_client::{ + ClientCapabilities, ClientInfo, Error as ClientError, McpClient, McpClientTrait, McpService, + StdioTransport, Transport, +}; +use std::time::Duration; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<(), ClientError> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::from_default_env() + .add_directive("mcp_client=debug".parse().unwrap()) + .add_directive("eventsource_client=debug".parse().unwrap()), + ) + .init(); + + // 1) Create the transport + let transport = StdioTransport::new("uvx", vec!["mcp-server-git".to_string()], HashMap::new()); + + // 2) Start the transport to get a handle + let transport_handle = transport.start().await?; + + // 3) Create the service with timeout middleware + let service = McpService::with_timeout(transport_handle, Duration::from_secs(10)); + + // 4) Create the client with the middleware-wrapped service + let mut client = McpClient::new(service); + + // Initialize + let server_info = client + .initialize( + ClientInfo { + name: "test-client".into(), + version: "1.0.0".into(), + }, + ClientCapabilities::default(), + ) + .await?; + println!("Connected to server: {server_info:?}\n"); + + // List tools + let tools = client.list_tools(None).await?; + println!("Available tools: {tools:?}\n"); + + // Call tool 'git_status' with arguments = {"repo_path": "."} + let tool_result = client + .call_tool("git_status", serde_json::json!({ "repo_path": "." })) + .await?; + println!("Tool result: {tool_result:?}\n"); + + // List resources + let resources = client.list_resources(None).await?; + println!("Available resources: {resources:?}\n"); + + Ok(()) +} diff --git a/crates/mcp-client/examples/stdio_integration.rs b/crates/mcp-client/examples/stdio_integration.rs new file mode 100644 index 00000000..9acd2086 --- /dev/null +++ b/crates/mcp-client/examples/stdio_integration.rs @@ -0,0 +1,86 @@ +// This example shows how to use the mcp-client crate to interact with a server that has a simple counter tool. +// The server is started by running `cargo run -p mcp-server` in the root of the mcp-server crate. +use anyhow::Result; +use mcp_client::client::{ + ClientCapabilities, ClientInfo, Error as ClientError, McpClient, McpClientTrait, +}; +use mcp_client::transport::{StdioTransport, Transport}; +use mcp_client::McpService; +use std::collections::HashMap; +use std::time::Duration; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<(), ClientError> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::from_default_env() + .add_directive("mcp_client=debug".parse().unwrap()) + .add_directive("eventsource_client=debug".parse().unwrap()), + ) + .init(); + + // Create the transport + let transport = StdioTransport::new( + "cargo", + vec!["run", "-p", "mcp-server"] + .into_iter() + .map(|s| s.to_string()) + .collect(), + HashMap::new(), + ); + + // Start the transport to get a handle + let transport_handle = transport.start().await.unwrap(); + + // Create the service with timeout middleware + let service = McpService::with_timeout(transport_handle, Duration::from_secs(10)); + + // Create client + let mut client = McpClient::new(service); + + // Initialize + let server_info = client + .initialize( + ClientInfo { + name: "test-client".into(), + version: "1.0.0".into(), + }, + ClientCapabilities::default(), + ) + .await?; + println!("Connected to server: {server_info:?}\n"); + + // List tools + let tools = client.list_tools(None).await?; + println!("Available tools: {tools:?}\n"); + + // Call tool 'increment' tool 3 times + for _ in 0..3 { + let increment_result = client.call_tool("increment", serde_json::json!({})).await?; + println!("Tool result for 'increment': {increment_result:?}\n"); + } + + // Call tool 'get_value' + let get_value_result = client.call_tool("get_value", serde_json::json!({})).await?; + println!("Tool result for 'get_value': {get_value_result:?}\n"); + + // Call tool 'decrement' once + let decrement_result = client.call_tool("decrement", serde_json::json!({})).await?; + println!("Tool result for 'decrement': {decrement_result:?}\n"); + + // Call tool 'get_value' + let get_value_result = client.call_tool("get_value", serde_json::json!({})).await?; + println!("Tool result for 'get_value': {get_value_result:?}\n"); + + // List resources + let resources = client.list_resources(None).await?; + println!("Resources: {resources:?}\n"); + + // Read resource + let resource = client.read_resource("memo://insights").await?; + println!("Resource: {resource:?}\n"); + + Ok(()) +} diff --git a/crates/mcp-client/src/client.rs b/crates/mcp-client/src/client.rs new file mode 100644 index 00000000..0a00e8c7 --- /dev/null +++ b/crates/mcp-client/src/client.rs @@ -0,0 +1,349 @@ +use mcp_core::protocol::{ + CallToolResult, Implementation, InitializeResult, JsonRpcError, JsonRpcMessage, + JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ListResourcesResult, ListToolsResult, + ReadResourceResult, ServerCapabilities, METHOD_NOT_FOUND, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::atomic::{AtomicU64, Ordering}; +use thiserror::Error; +use tokio::sync::Mutex; +use tower::{Service, ServiceExt}; // for Service::ready() + +pub type BoxError = Box; + +/// Error type for MCP client operations. +#[derive(Debug, Error)] +pub enum Error { + #[error("Transport error: {0}")] + Transport(#[from] super::transport::Error), + + #[error("RPC error: code={code}, message={message}")] + RpcError { code: i32, message: String }, + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Unexpected response from server: {0}")] + UnexpectedResponse(String), + + #[error("Not initialized")] + NotInitialized, + + #[error("Timeout or service not ready")] + NotReady, + + #[error("Request timed out")] + Timeout(#[from] tower::timeout::error::Elapsed), + + #[error("Error from mcp-server: {0}")] + ServerBoxError(BoxError), + + #[error("Call to '{server}' failed for '{method}'. {source}")] + McpServerError { + method: String, + server: String, + #[source] + source: BoxError, + }, +} + +// BoxError from mcp-server gets converted to our Error type +impl From for Error { + fn from(err: BoxError) -> Self { + Error::ServerBoxError(err) + } +} + +#[derive(Serialize, Deserialize)] +pub struct ClientInfo { + pub name: String, + pub version: String, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct ClientCapabilities { + // Add fields as needed. For now, empty capabilities are fine. +} + +#[derive(Serialize, Deserialize)] +pub struct InitializeParams { + #[serde(rename = "protocolVersion")] + pub protocol_version: String, + pub capabilities: ClientCapabilities, + #[serde(rename = "clientInfo")] + pub client_info: ClientInfo, +} + +#[async_trait::async_trait] +pub trait McpClientTrait: Send + Sync { + async fn initialize( + &mut self, + info: ClientInfo, + capabilities: ClientCapabilities, + ) -> Result; + + async fn list_resources( + &self, + next_cursor: Option, + ) -> Result; + + async fn read_resource(&self, uri: &str) -> Result; + + async fn list_tools(&self, next_cursor: Option) -> Result; + + async fn call_tool(&self, name: &str, arguments: Value) -> Result; +} + +/// The MCP client is the interface for MCP operations. +pub struct McpClient +where + S: Service + Clone + Send + Sync + 'static, + S::Error: Into, + S::Future: Send, +{ + service: Mutex, + next_id: AtomicU64, + server_capabilities: Option, + server_info: Option, +} + +impl McpClient +where + S: Service + Clone + Send + Sync + 'static, + S::Error: Into, + S::Future: Send, +{ + pub fn new(service: S) -> Self { + Self { + service: Mutex::new(service), + next_id: AtomicU64::new(1), + server_capabilities: None, + server_info: None, + } + } + + /// Send a JSON-RPC request and check we don't get an error response. + async fn send_request(&self, method: &str, params: Value) -> Result + where + R: for<'de> Deserialize<'de>, + { + let mut service = self.service.lock().await; + service.ready().await.map_err(|_| Error::NotReady)?; + + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(id), + method: method.to_string(), + params: Some(params.clone()), + }); + + let response_msg = service + .call(request) + .await + .map_err(|e| Error::McpServerError { + server: self + .server_info + .as_ref() + .map(|s| s.name.clone()) + .unwrap_or("".to_string()), + method: method.to_string(), + // we don't need include params because it can be really large + source: Box::new(e.into()), + })?; + + match response_msg { + JsonRpcMessage::Response(JsonRpcResponse { + id, result, error, .. + }) => { + // Verify id matches + if id != Some(self.next_id.load(Ordering::SeqCst) - 1) { + return Err(Error::UnexpectedResponse( + "id mismatch for JsonRpcResponse".to_string(), + )); + } + if let Some(err) = error { + Err(Error::RpcError { + code: err.code, + message: err.message, + }) + } else if let Some(r) = result { + Ok(serde_json::from_value(r)?) + } else { + Err(Error::UnexpectedResponse("missing result".to_string())) + } + } + JsonRpcMessage::Error(JsonRpcError { id, error, .. }) => { + if id != Some(self.next_id.load(Ordering::SeqCst) - 1) { + return Err(Error::UnexpectedResponse( + "id mismatch for JsonRpcError".to_string(), + )); + } + Err(Error::RpcError { + code: error.code, + message: error.message, + }) + } + _ => { + // Requests/notifications not expected as a response + Err(Error::UnexpectedResponse( + "unexpected message type".to_string(), + )) + } + } + } + + /// Send a JSON-RPC notification. + async fn send_notification(&self, method: &str, params: Value) -> Result<(), Error> { + let mut service = self.service.lock().await; + service.ready().await.map_err(|_| Error::NotReady)?; + + let notification = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + params: Some(params.clone()), + }); + + service + .call(notification) + .await + .map_err(|e| Error::McpServerError { + server: self + .server_info + .as_ref() + .map(|s| s.name.clone()) + .unwrap_or("".to_string()), + method: method.to_string(), + // we don't need include params because it can be really large + source: Box::new(e.into()), + })?; + + Ok(()) + } + + // Check if the client has completed initialization + fn completed_initialization(&self) -> bool { + self.server_capabilities.is_some() + } +} + +#[async_trait::async_trait] +impl McpClientTrait for McpClient +where + S: Service + Clone + Send + Sync + 'static, + S::Error: Into, + S::Future: Send, +{ + async fn initialize( + &mut self, + info: ClientInfo, + capabilities: ClientCapabilities, + ) -> Result { + let params = InitializeParams { + protocol_version: "1.0.0".into(), + client_info: info, + capabilities, + }; + let result: InitializeResult = self + .send_request("initialize", serde_json::to_value(params)?) + .await?; + + self.send_notification("notifications/initialized", serde_json::json!({})) + .await?; + + self.server_capabilities = Some(result.capabilities.clone()); + + self.server_info = Some(result.server_info.clone()); + + Ok(result) + } + + async fn list_resources( + &self, + next_cursor: Option, + ) -> Result { + if !self.completed_initialization() { + return Err(Error::NotInitialized); + } + // If resources is not supported, return an empty list + if self + .server_capabilities + .as_ref() + .unwrap() + .resources + .is_none() + { + return Ok(ListResourcesResult { + resources: vec![], + next_cursor: None, + }); + } + + let payload = next_cursor + .map(|cursor| serde_json::json!({"cursor": cursor})) + .unwrap_or_else(|| serde_json::json!({})); + + self.send_request("resources/list", payload).await + } + + async fn read_resource(&self, uri: &str) -> Result { + if !self.completed_initialization() { + return Err(Error::NotInitialized); + } + // If resources is not supported, return an error + if self + .server_capabilities + .as_ref() + .unwrap() + .resources + .is_none() + { + return Err(Error::RpcError { + code: METHOD_NOT_FOUND, + message: "Server does not support 'resources' capability".to_string(), + }); + } + + let params = serde_json::json!({ "uri": uri }); + self.send_request("resources/read", params).await + } + + async fn list_tools(&self, next_cursor: Option) -> Result { + if !self.completed_initialization() { + return Err(Error::NotInitialized); + } + // If tools is not supported, return an empty list + if self.server_capabilities.as_ref().unwrap().tools.is_none() { + return Ok(ListToolsResult { + tools: vec![], + next_cursor: None, + }); + } + + let payload = next_cursor + .map(|cursor| serde_json::json!({"cursor": cursor})) + .unwrap_or_else(|| serde_json::json!({})); + + self.send_request("tools/list", payload).await + } + + async fn call_tool(&self, name: &str, arguments: Value) -> Result { + if !self.completed_initialization() { + return Err(Error::NotInitialized); + } + // If tools is not supported, return an error + if self.server_capabilities.as_ref().unwrap().tools.is_none() { + return Err(Error::RpcError { + code: METHOD_NOT_FOUND, + message: "Server does not support 'tools' capability".to_string(), + }); + } + + let params = serde_json::json!({ "name": name, "arguments": arguments }); + + // TODO ERROR: check that if there is an error, we send back is_error: true with msg + // https://modelcontextprotocol.io/docs/concepts/tools#error-handling-2 + self.send_request("tools/call", params).await + } +} diff --git a/crates/mcp-client/src/lib.rs b/crates/mcp-client/src/lib.rs new file mode 100644 index 00000000..985d89d1 --- /dev/null +++ b/crates/mcp-client/src/lib.rs @@ -0,0 +1,7 @@ +pub mod client; +pub mod service; +pub mod transport; + +pub use client::{ClientCapabilities, ClientInfo, Error, McpClient, McpClientTrait}; +pub use service::McpService; +pub use transport::{SseTransport, StdioTransport, Transport, TransportHandle}; diff --git a/crates/mcp-client/src/service.rs b/crates/mcp-client/src/service.rs new file mode 100644 index 00000000..00aa95be --- /dev/null +++ b/crates/mcp-client/src/service.rs @@ -0,0 +1,52 @@ +use futures::future::BoxFuture; +use mcp_core::protocol::JsonRpcMessage; +use std::sync::Arc; +use std::task::{Context, Poll}; +use tower::{timeout::Timeout, Service, ServiceBuilder}; + +use crate::transport::{Error, TransportHandle}; + +/// A wrapper service that implements Tower's Service trait for MCP transport +#[derive(Clone)] +pub struct McpService { + inner: Arc, +} + +impl McpService { + pub fn new(transport: T) -> Self { + Self { + inner: Arc::new(transport), + } + } +} + +impl Service for McpService +where + T: TransportHandle + Send + Sync + 'static, +{ + type Response = JsonRpcMessage; + type Error = Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + // Most transports are always ready, but this could be customized if needed + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: JsonRpcMessage) -> Self::Future { + let transport = self.inner.clone(); + Box::pin(async move { transport.send(request).await }) + } +} + +// Add a convenience constructor for creating a service with timeout +impl McpService +where + T: TransportHandle, +{ + pub fn with_timeout(transport: T, timeout: std::time::Duration) -> Timeout> { + ServiceBuilder::new() + .timeout(timeout) + .service(McpService::new(transport)) + } +} diff --git a/crates/mcp-client/src/transport/mod.rs b/crates/mcp-client/src/transport/mod.rs new file mode 100644 index 00000000..25bcef74 --- /dev/null +++ b/crates/mcp-client/src/transport/mod.rs @@ -0,0 +1,127 @@ +use async_trait::async_trait; +use mcp_core::protocol::JsonRpcMessage; +use std::collections::HashMap; +use thiserror::Error; +use tokio::sync::{mpsc, oneshot, RwLock}; + +pub type BoxError = Box; +/// A generic error type for transport operations. +#[derive(Debug, Error)] +pub enum Error { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Transport was not connected or is already closed")] + NotConnected, + + #[error("Channel closed")] + ChannelClosed, + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Unsupported message type. JsonRpcMessage can only be Request or Notification.")] + UnsupportedMessage, + + #[error("Stdio process error: {0}")] + StdioProcessError(String), + + #[error("SSE connection error: {0}")] + SseConnection(String), + + #[error("HTTP error: {status} - {message}")] + HttpError { status: u16, message: String }, +} + +/// A message that can be sent through the transport +#[derive(Debug)] +pub struct TransportMessage { + /// The JSON-RPC message to send + pub message: JsonRpcMessage, + /// Channel to receive the response on (None for notifications) + pub response_tx: Option>>, +} + +/// A generic asynchronous transport trait with channel-based communication +#[async_trait] +pub trait Transport { + type Handle: TransportHandle; + + /// Start the transport and establish the underlying connection. + /// Returns the transport handle for sending messages. + async fn start(&self) -> Result; + + /// Close the transport and free any resources. + async fn close(&self) -> Result<(), Error>; +} + +#[async_trait] +pub trait TransportHandle: Send + Sync + Clone + 'static { + async fn send(&self, message: JsonRpcMessage) -> Result; +} + +// Helper function that contains the common send implementation +pub async fn send_message( + sender: &mpsc::Sender, + message: JsonRpcMessage, +) -> Result { + match message { + JsonRpcMessage::Request(request) => { + let (respond_to, response) = oneshot::channel(); + let msg = TransportMessage { + message: JsonRpcMessage::Request(request), + response_tx: Some(respond_to), + }; + sender.send(msg).await.map_err(|_| Error::ChannelClosed)?; + Ok(response.await.map_err(|_| Error::ChannelClosed)??) + } + JsonRpcMessage::Notification(notification) => { + let msg = TransportMessage { + message: JsonRpcMessage::Notification(notification), + response_tx: None, + }; + sender.send(msg).await.map_err(|_| Error::ChannelClosed)?; + Ok(JsonRpcMessage::Nil) + } + _ => Err(Error::UnsupportedMessage), + } +} + +// A data structure to store pending requests and their response channels +pub struct PendingRequests { + requests: RwLock>>>, +} + +impl Default for PendingRequests { + fn default() -> Self { + Self::new() + } +} + +impl PendingRequests { + pub fn new() -> Self { + Self { + requests: RwLock::new(HashMap::new()), + } + } + + pub async fn insert(&self, id: String, sender: oneshot::Sender>) { + self.requests.write().await.insert(id, sender); + } + + pub async fn respond(&self, id: &str, response: Result) { + if let Some(tx) = self.requests.write().await.remove(id) { + let _ = tx.send(response); + } + } + + pub async fn clear(&self) { + self.requests.write().await.clear(); + } +} + +pub mod stdio; +pub use stdio::StdioTransport; + +pub mod sse; +pub use sse::SseTransport; diff --git a/crates/mcp-client/src/transport/sse.rs b/crates/mcp-client/src/transport/sse.rs new file mode 100644 index 00000000..a5f05ed6 --- /dev/null +++ b/crates/mcp-client/src/transport/sse.rs @@ -0,0 +1,297 @@ +use crate::transport::{Error, PendingRequests, TransportMessage}; +use async_trait::async_trait; +use eventsource_client::{Client, SSE}; +use futures::TryStreamExt; +use mcp_core::protocol::{JsonRpcMessage, JsonRpcRequest}; +use reqwest::Client as HttpClient; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, RwLock}; +use tokio::time::{timeout, Duration}; +use tracing::warn; + +use super::{send_message, Transport, TransportHandle}; + +// Timeout for the endpoint discovery +const ENDPOINT_TIMEOUT_SECS: u64 = 5; + +/// The SSE-based actor that continuously: +/// - Reads incoming events from the SSE stream. +/// - Sends outgoing messages via HTTP POST (once the post endpoint is known). +pub struct SseActor { + /// Receives messages (requests/notifications) from the handle + receiver: mpsc::Receiver, + /// Map of request-id -> oneshot sender + pending_requests: Arc, + /// Base SSE URL + sse_url: String, + /// For sending HTTP POST requests + http_client: HttpClient, + /// The discovered endpoint for POST requests (once "endpoint" SSE event arrives) + post_endpoint: Arc>>, +} + +impl SseActor { + pub fn new( + receiver: mpsc::Receiver, + pending_requests: Arc, + sse_url: String, + post_endpoint: Arc>>, + ) -> Self { + Self { + receiver, + pending_requests, + sse_url, + post_endpoint, + http_client: HttpClient::new(), + } + } + + /// The main entry point for the actor. Spawns two concurrent loops: + /// 1) handle_incoming_messages (SSE events) + /// 2) handle_outgoing_messages (sending messages via POST) + pub async fn run(self) { + tokio::join!( + Self::handle_incoming_messages( + self.sse_url.clone(), + Arc::clone(&self.pending_requests), + Arc::clone(&self.post_endpoint) + ), + Self::handle_outgoing_messages( + self.receiver, + self.http_client.clone(), + Arc::clone(&self.post_endpoint), + Arc::clone(&self.pending_requests), + ) + ); + } + + /// Continuously reads SSE events from `sse_url`. + /// - If an `endpoint` event is received, store it in `post_endpoint`. + /// - If a `message` event is received, parse it as `JsonRpcMessage` + /// and respond to pending requests if it's a `Response`. + async fn handle_incoming_messages( + sse_url: String, + pending_requests: Arc, + post_endpoint: Arc>>, + ) { + let client = match eventsource_client::ClientBuilder::for_url(&sse_url) { + Ok(builder) => builder.build(), + Err(e) => { + pending_requests.clear().await; + warn!("Failed to connect SSE client: {}", e); + return; + } + }; + let mut stream = client.stream(); + + // First, wait for the "endpoint" event + while let Ok(Some(event)) = stream.try_next().await { + match event { + SSE::Event(e) if e.event_type == "endpoint" => { + // SSE server uses the "endpoint" event to tell us the POST URL + let base_url = sse_url.trim_end_matches('/').trim_end_matches("sse"); + let endpoint_path = e.data.trim_start_matches('/'); + let post_url = format!("{}{}", base_url, endpoint_path); + + println!("Discovered SSE POST endpoint: {post_url}"); + *post_endpoint.write().await = Some(post_url); + break; + } + _ => continue, + } + } + + // Now handle subsequent events + while let Ok(Some(event)) = stream.try_next().await { + match event { + SSE::Event(e) if e.event_type == "message" => { + // Attempt to parse the SSE data as a JsonRpcMessage + match serde_json::from_str::(&e.data) { + Ok(message) => { + // If it's a response, complete the pending request + if let JsonRpcMessage::Response(resp) = &message { + if let Some(id) = &resp.id { + pending_requests.respond(&id.to_string(), Ok(message)).await; + } + } + // If it's something else (notification, etc.), handle as needed + } + Err(err) => { + warn!("Failed to parse SSE message: {err}"); + } + } + } + _ => { /* ignore other events */ } + } + } + + // SSE stream ended or errored; signal any pending requests + eprintln!("SSE stream ended or encountered an error; clearing pending requests."); + pending_requests.clear().await; + } + + /// Continuously receives messages from the `mpsc::Receiver`. + /// - If it's a request, store the oneshot in `pending_requests`. + /// - POST the message to the discovered endpoint (once known). + async fn handle_outgoing_messages( + mut receiver: mpsc::Receiver, + http_client: HttpClient, + post_endpoint: Arc>>, + pending_requests: Arc, + ) { + while let Some(transport_msg) = receiver.recv().await { + let post_url = match post_endpoint.read().await.as_ref() { + Some(url) => url.clone(), + None => { + if let Some(response_tx) = transport_msg.response_tx { + let _ = response_tx.send(Err(Error::NotConnected)); + } + continue; + } + }; + + // Serialize the JSON-RPC message + let message_str = match serde_json::to_string(&transport_msg.message) { + Ok(s) => s, + Err(e) => { + if let Some(tx) = transport_msg.response_tx { + let _ = tx.send(Err(Error::Serialization(e))); + } + continue; + } + }; + + // If it's a request, store the channel so we can respond later + if let Some(response_tx) = transport_msg.response_tx { + if let JsonRpcMessage::Request(JsonRpcRequest { id: Some(id), .. }) = + &transport_msg.message + { + pending_requests.insert(id.to_string(), response_tx).await; + } + } + + // Perform the HTTP POST + match http_client + .post(&post_url) + .header("Content-Type", "application/json") + .body(message_str) + .send() + .await + { + Ok(resp) => { + if !resp.status().is_success() { + let err = Error::HttpError { + status: resp.status().as_u16(), + message: resp.status().to_string(), + }; + warn!("HTTP request returned error: {err}"); + // This doesn't directly fail the request, + // because we rely on SSE to deliver the error response + } + } + Err(e) => { + warn!("HTTP POST failed: {e}"); + // Similarly, SSE might eventually reveal the error + } + } + } + + // mpsc channel closed => no more outgoing messages + eprintln!("SseActor: outgoing message loop ended. Clearing pending requests."); + pending_requests.clear().await; + } +} + +#[derive(Clone)] +pub struct SseTransportHandle { + sender: mpsc::Sender, +} + +#[async_trait::async_trait] +impl TransportHandle for SseTransportHandle { + async fn send(&self, message: JsonRpcMessage) -> Result { + send_message(&self.sender, message).await + } +} + +#[derive(Clone)] +pub struct SseTransport { + sse_url: String, + env: HashMap, +} + +/// The SSE transport spawns an `SseActor` on `start()`. +impl SseTransport { + pub fn new>(sse_url: S, env: HashMap) -> Self { + Self { + sse_url: sse_url.into(), + env, + } + } + + /// Waits for the endpoint to be set, up to 10 attempts. + async fn wait_for_endpoint( + post_endpoint: Arc>>, + ) -> Result { + // Check every 100ms for the endpoint, for up to 10 attempts + let check_interval = Duration::from_millis(100); + let mut attempts = 0; + let max_attempts = 10; + + while attempts < max_attempts { + if let Some(url) = post_endpoint.read().await.clone() { + return Ok(url); + } + tokio::time::sleep(check_interval).await; + attempts += 1; + } + Err(Error::SseConnection("No endpoint discovered".to_string())) + } +} + +#[async_trait] +impl Transport for SseTransport { + type Handle = SseTransportHandle; + + async fn start(&self) -> Result { + // Set environment variables + for (key, value) in &self.env { + std::env::set_var(key, value); + } + + // Create a channel for outgoing TransportMessages + let (tx, rx) = mpsc::channel(32); + + let post_endpoint: Arc>> = Arc::new(RwLock::new(None)); + let post_endpoint_clone = Arc::clone(&post_endpoint); + + // Build the actor + let actor = SseActor::new( + rx, + Arc::new(PendingRequests::new()), + self.sse_url.clone(), + post_endpoint, + ); + + // Spawn the actor task + tokio::spawn(actor.run()); + + // Wait for the endpoint to be discovered before returning the handle + match timeout( + Duration::from_secs(ENDPOINT_TIMEOUT_SECS), + Self::wait_for_endpoint(post_endpoint_clone), + ) + .await + { + Ok(_) => Ok(SseTransportHandle { sender: tx }), + Err(e) => Err(Error::SseConnection(e.to_string())), + } + } + + async fn close(&self) -> Result<(), Error> { + // For SSE, you might close the stream or send a shutdown signal to the actor. + // Here, we do nothing special. + Ok(()) + } +} diff --git a/crates/mcp-client/src/transport/stdio.rs b/crates/mcp-client/src/transport/stdio.rs new file mode 100644 index 00000000..2ee4234d --- /dev/null +++ b/crates/mcp-client/src/transport/stdio.rs @@ -0,0 +1,261 @@ +use std::collections::HashMap; +use std::sync::Arc; +use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; + +use async_trait::async_trait; +use mcp_core::protocol::JsonRpcMessage; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, Mutex}; + +use super::{send_message, Error, PendingRequests, Transport, TransportHandle, TransportMessage}; + +/// A `StdioTransport` uses a child process's stdin/stdout as a communication channel. +/// +/// It uses channels for message passing and handles responses asynchronously through a background task. +pub struct StdioActor { + receiver: mpsc::Receiver, + pending_requests: Arc, + _process: Child, // we store the process to keep it alive + error_sender: mpsc::Sender, + stdin: ChildStdin, + stdout: ChildStdout, + stderr: ChildStderr, +} + +impl StdioActor { + pub async fn run(mut self) { + use tokio::pin; + + let incoming = Self::handle_incoming_messages(self.stdout, self.pending_requests.clone()); + let outgoing = Self::handle_outgoing_messages( + self.receiver, + self.stdin, + self.pending_requests.clone(), + ); + + // take ownership of futures for tokio::select + pin!(incoming); + pin!(outgoing); + + // Use select! to wait for either I/O completion or process exit + tokio::select! { + result = &mut incoming => { + tracing::debug!("Stdin handler completed: {:?}", result); + } + result = &mut outgoing => { + tracing::debug!("Stdout handler completed: {:?}", result); + } + // capture the status so we don't need to wait for a timeout + status = self._process.wait() => { + tracing::debug!("Process exited with status: {:?}", status); + } + } + + // Then always try to read stderr before cleaning up + let mut stderr_buffer = Vec::new(); + if let Ok(bytes) = self.stderr.read_to_end(&mut stderr_buffer).await { + let err_msg = if bytes > 0 { + String::from_utf8_lossy(&stderr_buffer).to_string() + } else { + "Process ended unexpectedly".to_string() + }; + + tracing::info!("Process stderr: {}", err_msg); + let _ = self + .error_sender + .send(Error::StdioProcessError(err_msg)) + .await; + } + + // Clean up regardless of which path we took + self.pending_requests.clear().await; + } + + async fn handle_incoming_messages(stdout: ChildStdout, pending_requests: Arc) { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + loop { + match reader.read_line(&mut line).await { + Ok(0) => { + tracing::error!("Child process ended (EOF on stdout)"); + break; + } // EOF + Ok(_) => { + if let Ok(message) = serde_json::from_str::(&line) { + tracing::debug!( + message = ?message, + "Received incoming message" + ); + + if let JsonRpcMessage::Response(response) = &message { + if let Some(id) = &response.id { + pending_requests.respond(&id.to_string(), Ok(message)).await; + } + } + } + line.clear(); + } + Err(e) => { + tracing::error!(error = ?e, "Error reading line"); + break; + } + } + } + } + + async fn handle_outgoing_messages( + mut receiver: mpsc::Receiver, + mut stdin: ChildStdin, + pending_requests: Arc, + ) { + while let Some(mut transport_msg) = receiver.recv().await { + let message_str = match serde_json::to_string(&transport_msg.message) { + Ok(s) => s, + Err(e) => { + if let Some(tx) = transport_msg.response_tx.take() { + let _ = tx.send(Err(Error::Serialization(e))); + } + continue; + } + }; + + tracing::debug!(message = ?transport_msg.message, "Sending outgoing message"); + + if let Some(response_tx) = transport_msg.response_tx.take() { + if let JsonRpcMessage::Request(request) = &transport_msg.message { + if let Some(id) = &request.id { + pending_requests.insert(id.to_string(), response_tx).await; + } + } + } + + if let Err(e) = stdin + .write_all(format!("{}\n", message_str).as_bytes()) + .await + { + tracing::error!(error = ?e, "Error writing message to child process"); + pending_requests.clear().await; + break; + } + + if let Err(e) = stdin.flush().await { + tracing::error!(error = ?e, "Error flushing message to child process"); + pending_requests.clear().await; + break; + } + } + } +} + +#[derive(Clone)] +pub struct StdioTransportHandle { + sender: mpsc::Sender, + error_receiver: Arc>>, +} + +#[async_trait::async_trait] +impl TransportHandle for StdioTransportHandle { + async fn send(&self, message: JsonRpcMessage) -> Result { + let result = send_message(&self.sender, message).await; + // Check for any pending errors even if send is successful + self.check_for_errors().await?; + result + } +} + +impl StdioTransportHandle { + /// Check if there are any process errors + pub async fn check_for_errors(&self) -> Result<(), Error> { + match self.error_receiver.lock().await.try_recv() { + Ok(error) => { + tracing::debug!("Found error: {:?}", error); + Err(error) + } + Err(_) => Ok(()), + } + } +} + +pub struct StdioTransport { + command: String, + args: Vec, + env: HashMap, +} + +impl StdioTransport { + pub fn new>( + command: S, + args: Vec, + env: HashMap, + ) -> Self { + Self { + command: command.into(), + args, + env, + } + } + + async fn spawn_process(&self) -> Result<(Child, ChildStdin, ChildStdout, ChildStderr), Error> { + let mut process = Command::new(&self.command) + .envs(&self.env) + .args(&self.args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + // 0 sets the process group ID equal to the process ID + .process_group(0) // don't inherit signal handling from parent process + .spawn() + .map_err(|e| Error::StdioProcessError(e.to_string()))?; + + let stdin = process + .stdin + .take() + .ok_or_else(|| Error::StdioProcessError("Failed to get stdin".into()))?; + + let stdout = process + .stdout + .take() + .ok_or_else(|| Error::StdioProcessError("Failed to get stdout".into()))?; + + let stderr = process + .stderr + .take() + .ok_or_else(|| Error::StdioProcessError("Failed to get stderr".into()))?; + + Ok((process, stdin, stdout, stderr)) + } +} + +#[async_trait] +impl Transport for StdioTransport { + type Handle = StdioTransportHandle; + + async fn start(&self) -> Result { + let (process, stdin, stdout, stderr) = self.spawn_process().await?; + let (message_tx, message_rx) = mpsc::channel(32); + let (error_tx, error_rx) = mpsc::channel(1); + + let actor = StdioActor { + receiver: message_rx, + pending_requests: Arc::new(PendingRequests::new()), + _process: process, + error_sender: error_tx, + stdin, + stdout, + stderr, + }; + + tokio::spawn(actor.run()); + + let handle = StdioTransportHandle { + sender: message_tx, + error_receiver: Arc::new(Mutex::new(error_rx)), + }; + Ok(handle) + } + + async fn close(&self) -> Result<(), Error> { + Ok(()) + } +} diff --git a/crates/mcp-core/Cargo.toml b/crates/mcp-core/Cargo.toml new file mode 100644 index 00000000..d2d613dc --- /dev/null +++ b/crates/mcp-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "mcp-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +schemars = "0.8" +anyhow = "1.0" +chrono = { version = "0.4.38", features = ["serde"] } +url = "2.5" +base64 = "0.21" + +[dev-dependencies] +tempfile = "3.8" diff --git a/crates/mcp-core/src/content.rs b/crates/mcp-core/src/content.rs new file mode 100644 index 00000000..01c44be4 --- /dev/null +++ b/crates/mcp-core/src/content.rs @@ -0,0 +1,310 @@ +/// Content sent around agents, extensions, and LLMs +/// The various content types can be display to humans but also understood by models +/// They include optional annotations used to help inform agent usage +use super::role::Role; +use crate::resource::ResourceContents; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Annotations { + #[serde(skip_serializing_if = "Option::is_none")] + pub audience: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option>, +} + +impl Annotations { + /// Creates a new Annotations instance specifically for resources + /// optional priority, and a timestamp (defaults to now if None) + pub fn for_resource(priority: f32, timestamp: DateTime) -> Self { + assert!( + (0.0..=1.0).contains(&priority), + "Priority {priority} must be between 0.0 and 1.0" + ); + Annotations { + priority: Some(priority), + timestamp: Some(timestamp), + audience: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextContent { + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageContent { + pub data: String, + pub mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EmbeddedResource { + pub resource: ResourceContents, + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +impl EmbeddedResource { + pub fn get_text(&self) -> String { + match &self.resource { + ResourceContents::TextResourceContents { text, .. } => text.clone(), + _ => String::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Content { + Text(TextContent), + Image(ImageContent), + Resource(EmbeddedResource), +} + +impl Content { + pub fn text>(text: S) -> Self { + Content::Text(TextContent { + text: text.into(), + annotations: None, + }) + } + + pub fn image, T: Into>(data: S, mime_type: T) -> Self { + Content::Image(ImageContent { + data: data.into(), + mime_type: mime_type.into(), + annotations: None, + }) + } + + pub fn resource(resource: ResourceContents) -> Self { + Content::Resource(EmbeddedResource { + resource, + annotations: None, + }) + } + + pub fn embedded_text, T: Into>(uri: S, content: T) -> Self { + Content::Resource(EmbeddedResource { + resource: ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("text".to_string()), + text: content.into(), + }, + annotations: None, + }) + } + + /// Get the text content if this is a TextContent variant + pub fn as_text(&self) -> Option<&str> { + match self { + Content::Text(text) => Some(&text.text), + _ => None, + } + } + + /// Get the image content if this is an ImageContent variant + pub fn as_image(&self) -> Option<(&str, &str)> { + match self { + Content::Image(image) => Some((&image.data, &image.mime_type)), + _ => None, + } + } + + /// Set the audience for the content + pub fn with_audience(mut self, audience: Vec) -> Self { + let annotations = match &mut self { + Content::Text(text) => &mut text.annotations, + Content::Image(image) => &mut image.annotations, + Content::Resource(resource) => &mut resource.annotations, + }; + *annotations = Some(match annotations.take() { + Some(mut a) => { + a.audience = Some(audience); + a + } + None => Annotations { + audience: Some(audience), + priority: None, + timestamp: None, + }, + }); + self + } + + /// Set the priority for the content + /// # Panics + /// Panics if priority is not between 0.0 and 1.0 inclusive + pub fn with_priority(mut self, priority: f32) -> Self { + if !(0.0..=1.0).contains(&priority) { + panic!("Priority must be between 0.0 and 1.0"); + } + let annotations = match &mut self { + Content::Text(text) => &mut text.annotations, + Content::Image(image) => &mut image.annotations, + Content::Resource(resource) => &mut resource.annotations, + }; + *annotations = Some(match annotations.take() { + Some(mut a) => { + a.priority = Some(priority); + a + } + None => Annotations { + audience: None, + priority: Some(priority), + timestamp: None, + }, + }); + self + } + + /// Get the audience if set + pub fn audience(&self) -> Option<&Vec> { + match self { + Content::Text(text) => text.annotations.as_ref().and_then(|a| a.audience.as_ref()), + Content::Image(image) => image.annotations.as_ref().and_then(|a| a.audience.as_ref()), + Content::Resource(resource) => resource + .annotations + .as_ref() + .and_then(|a| a.audience.as_ref()), + } + } + + /// Get the priority if set + pub fn priority(&self) -> Option { + match self { + Content::Text(text) => text.annotations.as_ref().and_then(|a| a.priority), + Content::Image(image) => image.annotations.as_ref().and_then(|a| a.priority), + Content::Resource(resource) => resource.annotations.as_ref().and_then(|a| a.priority), + } + } + + pub fn unannotated(&self) -> Self { + match self { + Content::Text(text) => Content::text(text.text.clone()), + Content::Image(image) => Content::image(image.data.clone(), image.mime_type.clone()), + Content::Resource(resource) => Content::resource(resource.resource.clone()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_content_text() { + let content = Content::text("hello"); + assert_eq!(content.as_text(), Some("hello")); + assert_eq!(content.as_image(), None); + } + + #[test] + fn test_content_image() { + let content = Content::image("data", "image/png"); + assert_eq!(content.as_text(), None); + assert_eq!(content.as_image(), Some(("data", "image/png"))); + } + + #[test] + fn test_content_annotations_basic() { + let content = Content::text("hello") + .with_audience(vec![Role::User]) + .with_priority(0.5); + assert_eq!(content.audience(), Some(&vec![Role::User])); + assert_eq!(content.priority(), Some(0.5)); + } + + #[test] + fn test_content_annotations_order_independence() { + let content1 = Content::text("hello") + .with_audience(vec![Role::User]) + .with_priority(0.5); + let content2 = Content::text("hello") + .with_priority(0.5) + .with_audience(vec![Role::User]); + + assert_eq!(content1.audience(), content2.audience()); + assert_eq!(content1.priority(), content2.priority()); + } + + #[test] + fn test_content_annotations_overwrite() { + let content = Content::text("hello") + .with_audience(vec![Role::User]) + .with_priority(0.5) + .with_audience(vec![Role::Assistant]) + .with_priority(0.8); + + assert_eq!(content.audience(), Some(&vec![Role::Assistant])); + assert_eq!(content.priority(), Some(0.8)); + } + + #[test] + fn test_content_annotations_image() { + let content = Content::image("data", "image/png") + .with_audience(vec![Role::User]) + .with_priority(0.5); + + assert_eq!(content.audience(), Some(&vec![Role::User])); + assert_eq!(content.priority(), Some(0.5)); + } + + #[test] + fn test_content_annotations_preservation() { + let text_content = Content::text("hello") + .with_audience(vec![Role::User]) + .with_priority(0.5); + + match &text_content { + Content::Text(TextContent { annotations, .. }) => { + assert!(annotations.is_some()); + let ann = annotations.as_ref().unwrap(); + assert_eq!(ann.audience, Some(vec![Role::User])); + assert_eq!(ann.priority, Some(0.5)); + } + _ => panic!("Expected Text content"), + } + } + + #[test] + #[should_panic(expected = "Priority must be between 0.0 and 1.0")] + fn test_invalid_priority() { + Content::text("hello").with_priority(1.5); + } + + #[test] + fn test_unannotated() { + let content = Content::text("hello") + .with_audience(vec![Role::User]) + .with_priority(0.5); + let unannotated = content.unannotated(); + assert_eq!(unannotated.audience(), None); + assert_eq!(unannotated.priority(), None); + } + + #[test] + fn test_partial_annotations() { + let content = Content::text("hello").with_priority(0.5); + assert_eq!(content.audience(), None); + assert_eq!(content.priority(), Some(0.5)); + + let content = Content::text("hello").with_audience(vec![Role::User]); + assert_eq!(content.audience(), Some(&vec![Role::User])); + assert_eq!(content.priority(), None); + } +} diff --git a/crates/mcp-core/src/handler.rs b/crates/mcp-core/src/handler.rs new file mode 100644 index 00000000..338fe94e --- /dev/null +++ b/crates/mcp-core/src/handler.rs @@ -0,0 +1,73 @@ +use async_trait::async_trait; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +#[non_exhaustive] +#[derive(Error, Debug, Clone, Deserialize, Serialize, PartialEq)] +pub enum ToolError { + #[error("Invalid parameters: {0}")] + InvalidParameters(String), + #[error("Execution failed: {0}")] + ExecutionError(String), + #[error("Schema error: {0}")] + SchemaError(String), + #[error("Tool not found: {0}")] + NotFound(String), +} + +pub type ToolResult = std::result::Result; + +#[derive(Error, Debug)] +pub enum ResourceError { + #[error("Execution failed: {0}")] + ExecutionError(String), + #[error("Resource not found: {0}")] + NotFound(String), +} + +#[derive(Error, Debug)] +pub enum PromptError { + #[error("Invalid parameters: {0}")] + InvalidParameters(String), + #[error("Internal error: {0}")] + InternalError(String), + #[error("Prompt not found: {0}")] + NotFound(String), +} + +/// Trait for implementing MCP tools +#[async_trait] +pub trait ToolHandler: Send + Sync + 'static { + /// The name of the tool + fn name(&self) -> &'static str; + + /// A description of what the tool does + fn description(&self) -> &'static str; + + /// JSON schema describing the tool's parameters + fn schema(&self) -> Value; + + /// Execute the tool with the given parameters + async fn call(&self, params: Value) -> ToolResult; +} + +/// Trait for implementing MCP resources +#[async_trait] +pub trait ResourceTemplateHandler: Send + Sync + 'static { + /// The URL template for this resource + fn template() -> &'static str; + + /// JSON schema describing the resource parameters + fn schema() -> Value; + + /// Get the resource value + async fn get(&self, params: Value) -> ToolResult; +} + +/// Helper function to generate JSON schema for a type +pub fn generate_schema() -> ToolResult { + let schema = schemars::schema_for!(T); + serde_json::to_value(schema).map_err(|e| ToolError::SchemaError(e.to_string())) +} diff --git a/crates/mcp-core/src/lib.rs b/crates/mcp-core/src/lib.rs new file mode 100644 index 00000000..5a37ceea --- /dev/null +++ b/crates/mcp-core/src/lib.rs @@ -0,0 +1,12 @@ +pub mod content; +pub use content::{Annotations, Content, ImageContent, TextContent}; +pub mod handler; +pub mod role; +pub use role::Role; +pub mod tool; +pub use tool::{Tool, ToolCall}; +pub mod resource; +pub use resource::{Resource, ResourceContents}; +pub mod protocol; +pub use handler::{ToolError, ToolResult}; +pub mod prompt; diff --git a/crates/mcp-core/src/prompt.rs b/crates/mcp-core/src/prompt.rs new file mode 100644 index 00000000..7b814fd4 --- /dev/null +++ b/crates/mcp-core/src/prompt.rs @@ -0,0 +1,156 @@ +use crate::content::{Annotations, EmbeddedResource, ImageContent}; +use crate::handler::PromptError; +use crate::resource::ResourceContents; +use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine}; +use serde::{Deserialize, Serialize}; + +/// A prompt that can be used to generate text from a model +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Prompt { + /// The name of the prompt + pub name: String, + /// A description of what the prompt does + pub description: String, + /// The arguments that can be passed to customize the prompt + pub arguments: Vec, +} + +impl Prompt { + /// Create a new prompt with the given name, description and arguments + pub fn new(name: N, description: D, arguments: Vec) -> Self + where + N: Into, + D: Into, + { + Prompt { + name: name.into(), + description: description.into(), + arguments, + } + } +} + +/// Represents a prompt argument that can be passed to customize the prompt +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PromptArgument { + /// The name of the argument + pub name: String, + /// A description of what the argument is used for + pub description: String, + /// Whether this argument is required + pub required: bool, +} + +/// Represents the role of a message sender in a prompt conversation +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PromptMessageRole { + User, + Assistant, +} + +/// Content types that can be included in prompt messages +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum PromptMessageContent { + /// Plain text content + Text { text: String }, + /// Image content with base64-encoded data + Image { image: ImageContent }, + /// Embedded server-side resource + Resource { resource: EmbeddedResource }, +} + +/// A message in a prompt conversation +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PromptMessage { + /// The role of the message sender + pub role: PromptMessageRole, + /// The content of the message + pub content: PromptMessageContent, +} + +impl PromptMessage { + /// Create a new text message with the given role and text content + pub fn new_text>(role: PromptMessageRole, text: S) -> Self { + Self { + role, + content: PromptMessageContent::Text { text: text.into() }, + } + } + + pub fn new_image>( + role: PromptMessageRole, + data: S, + mime_type: S, + annotations: Option, + ) -> Result { + let data = data.into(); + let mime_type = mime_type.into(); + + // Validate base64 data + BASE64_STANDARD.decode(&data).map_err(|_| { + PromptError::InvalidParameters("Image data must be valid base64".to_string()) + })?; + + // Validate mime type + if !mime_type.starts_with("image/") { + return Err(PromptError::InvalidParameters( + "MIME type must be a valid image type (e.g. image/jpeg)".to_string(), + )); + } + + Ok(Self { + role, + content: PromptMessageContent::Image { + image: ImageContent { + data, + mime_type, + annotations, + }, + }, + }) + } + + /// Create a new resource message + pub fn new_resource( + role: PromptMessageRole, + uri: String, + mime_type: String, + text: Option, + annotations: Option, + ) -> Self { + let resource_contents = ResourceContents::TextResourceContents { + uri, + mime_type: Some(mime_type), + text: text.unwrap_or_default(), + }; + + Self { + role, + content: PromptMessageContent::Resource { + resource: EmbeddedResource { + resource: resource_contents, + annotations, + }, + }, + } + } +} + +/// A template for a prompt +#[derive(Debug, Serialize, Deserialize)] +pub struct PromptTemplate { + pub id: String, + pub template: String, + pub arguments: Vec, +} + +/// A template for a prompt argument, this should be identical to PromptArgument +#[derive(Debug, Serialize, Deserialize)] +pub struct PromptArgumentTemplate { + pub name: String, + pub description: String, + pub required: bool, +} diff --git a/crates/mcp-core/src/protocol.rs b/crates/mcp-core/src/protocol.rs new file mode 100644 index 00000000..cea4a2b2 --- /dev/null +++ b/crates/mcp-core/src/protocol.rs @@ -0,0 +1,238 @@ +/// The protocol messages exchanged between client and server +use crate::{ + content::Content, + prompt::{Prompt, PromptMessage}, + resource::Resource, + resource::ResourceContents, + tool::Tool, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct JsonRpcNotification { + pub jsonrpc: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct JsonRpcError { + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub error: ErrorData, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(untagged, try_from = "JsonRpcRaw")] +pub enum JsonRpcMessage { + Request(JsonRpcRequest), + Response(JsonRpcResponse), + Notification(JsonRpcNotification), + Error(JsonRpcError), + Nil, // used to respond to notifications +} + +#[derive(Debug, Serialize, Deserialize)] +struct JsonRpcRaw { + jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl TryFrom for JsonRpcMessage { + type Error = String; + + fn try_from(raw: JsonRpcRaw) -> Result>::Error> { + // If it has an error field, it's an error response + if raw.error.is_some() { + return Ok(JsonRpcMessage::Error(JsonRpcError { + jsonrpc: raw.jsonrpc, + id: raw.id, + error: raw.error.unwrap(), + })); + } + + // If it has a result field, it's a response + if raw.result.is_some() { + return Ok(JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: raw.jsonrpc, + id: raw.id, + result: raw.result, + error: None, + })); + } + + // If we have a method, it's either a notification or request + if let Some(method) = raw.method { + if method.starts_with("notifications/") { + return Ok(JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: raw.jsonrpc, + method, + params: raw.params, + })); + } + + return Ok(JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: raw.jsonrpc, + id: raw.id, + method, + params: raw.params, + })); + } + + // If we have no method and no result/error, it's a nil response + if raw.id.is_none() && raw.result.is_none() && raw.error.is_none() { + return Ok(JsonRpcMessage::Nil); + } + + // If we get here, something is wrong with the message + Err(format!( + "Invalid JSON-RPC message format: id={:?}, method={:?}, result={:?}, error={:?}", + raw.id, raw.method, raw.result, raw.error + )) + } +} + +// Standard JSON-RPC error codes +pub const PARSE_ERROR: i32 = -32700; +pub const INVALID_REQUEST: i32 = -32600; +pub const METHOD_NOT_FOUND: i32 = -32601; +pub const INVALID_PARAMS: i32 = -32602; +pub const INTERNAL_ERROR: i32 = -32603; + +/// Error information for JSON-RPC error responses. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ErrorData { + /// The error type that occurred. + pub code: i32, + + /// A short description of the error. The message SHOULD be limited to a concise single sentence. + pub message: String, + + /// Additional information about the error. The value of this member is defined by the + /// sender (e.g. detailed error information, nested errors etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub protocol_version: String, + pub capabilities: ServerCapabilities, + pub server_info: Implementation, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct Implementation { + pub name: String, + pub version: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ServerCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub prompts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resources: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, + // Add other capabilities as needed +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PromptsCapability { + pub list_changed: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ResourcesCapability { + pub subscribe: Option, + pub list_changed: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ToolsCapability { + pub list_changed: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ListResourcesResult { + pub resources: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ReadResourceResult { + pub contents: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ListToolsResult { + pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CallToolResult { + pub content: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct ListPromptsResult { + pub prompts: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct GetPromptResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub messages: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct EmptyResult {} diff --git a/crates/mcp-core/src/resource.rs b/crates/mcp-core/src/resource.rs new file mode 100644 index 00000000..a81155c8 --- /dev/null +++ b/crates/mcp-core/src/resource.rs @@ -0,0 +1,260 @@ +/// Resources that servers provide to clients +use anyhow::{anyhow, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::content::Annotations; + +const EPSILON: f32 = 1e-6; // Tolerance for floating point comparison + +/// Represents a resource in the extension with metadata +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Resource { + /// URI representing the resource location (e.g., "file:///path/to/file" or "str:///content") + pub uri: String, + /// Name of the resource + pub name: String, + /// Optional description of the resource + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// MIME type of the resource content ("text" or "blob") + #[serde(default = "default_mime_type")] + pub mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "camelCase", untagged)] +pub enum ResourceContents { + TextResourceContents { + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + mime_type: Option, + text: String, + }, + BlobResourceContents { + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + mime_type: Option, + blob: String, + }, +} + +fn default_mime_type() -> String { + "text".to_string() +} + +impl Resource { + /// Creates a new Resource from a URI with explicit mime type + pub fn new>( + uri: S, + mime_type: Option, + name: Option, + ) -> Result { + let uri = uri.as_ref(); + let url = Url::parse(uri).map_err(|e| anyhow!("Invalid URI: {}", e))?; + + // Extract name from the path component of the URI + // Use provided name if available, otherwise extract from URI + let name = match name { + Some(n) => n, + None => url + .path_segments() + .and_then(|segments| segments.last()) + .unwrap_or("unnamed") + .to_string(), + }; + + // Use provided mime_type or default + let mime_type = match mime_type { + Some(t) if t == "text" || t == "blob" => t, + _ => default_mime_type(), + }; + + Ok(Self { + uri: uri.to_string(), + name, + description: None, + mime_type, + annotations: Some(Annotations::for_resource(0.0, Utc::now())), + }) + } + + /// Creates a new Resource with explicit URI, name, and priority + pub fn with_uri>( + uri: S, + name: S, + priority: f32, + mime_type: Option, + ) -> Result { + let uri_string = uri.into(); + Url::parse(&uri_string).map_err(|e| anyhow!("Invalid URI: {}", e))?; + + // Use provided mime_type or default + let mime_type = match mime_type { + Some(t) if t == "text" || t == "blob" => t, + _ => default_mime_type(), + }; + + Ok(Self { + uri: uri_string, + name: name.into(), + description: None, + mime_type, + annotations: Some(Annotations::for_resource(priority, Utc::now())), + }) + } + + /// Updates the resource's timestamp to the current time + pub fn update_timestamp(&mut self) { + self.annotations.as_mut().unwrap().timestamp = Some(Utc::now()); + } + + /// Sets the priority of the resource and returns self for method chaining + pub fn with_priority(mut self, priority: f32) -> Self { + self.annotations.as_mut().unwrap().priority = Some(priority); + self + } + + /// Mark the resource as active, i.e. set its priority to 1.0 + pub fn mark_active(self) -> Self { + self.with_priority(1.0) + } + + // Check if the resource is active + pub fn is_active(&self) -> bool { + if let Some(priority) = self.priority() { + (priority - 1.0).abs() < EPSILON + } else { + false + } + } + + /// Returns the priority of the resource, if set + pub fn priority(&self) -> Option { + self.annotations.as_ref().and_then(|a| a.priority) + } + + /// Returns the timestamp of the resource, if set + pub fn timestamp(&self) -> Option> { + self.annotations.as_ref().and_then(|a| a.timestamp) + } + + /// Returns the scheme of the URI + pub fn scheme(&self) -> Result { + let url = Url::parse(&self.uri)?; + Ok(url.scheme().to_string()) + } + + /// Sets the description of the resource + pub fn with_description>(mut self, description: S) -> Self { + self.description = Some(description.into()); + self + } + + /// Sets the MIME type of the resource + pub fn with_mime_type>(mut self, mime_type: S) -> Self { + let mime_type = mime_type.into(); + match mime_type.as_str() { + "text" | "blob" => self.mime_type = mime_type, + _ => self.mime_type = default_mime_type(), + } + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_new_resource_with_file_uri() -> Result<()> { + let mut temp_file = NamedTempFile::new()?; + writeln!(temp_file, "test content")?; + + let uri = Url::from_file_path(temp_file.path()) + .map_err(|_| anyhow!("Invalid file path"))? + .to_string(); + + let resource = Resource::new(&uri, Some("text".to_string()), None)?; + assert!(resource.uri.starts_with("file:///")); + assert_eq!(resource.priority(), Some(0.0)); + assert_eq!(resource.mime_type, "text"); + assert_eq!(resource.scheme()?, "file"); + + Ok(()) + } + + #[test] + fn test_resource_with_str_uri() -> Result<()> { + let test_content = "Hello, world!"; + let uri = format!("str:///{}", test_content); + let resource = Resource::with_uri( + uri.clone(), + "test.txt".to_string(), + 0.5, + Some("text".to_string()), + )?; + + assert_eq!(resource.uri, uri); + assert_eq!(resource.name, "test.txt"); + assert_eq!(resource.priority(), Some(0.5)); + assert_eq!(resource.mime_type, "text"); + assert_eq!(resource.scheme()?, "str"); + + Ok(()) + } + + #[test] + fn test_mime_type_validation() -> Result<()> { + // Test valid mime types + let resource = Resource::new("file:///test.txt", Some("text".to_string()), None)?; + assert_eq!(resource.mime_type, "text"); + + let resource = Resource::new("file:///test.bin", Some("blob".to_string()), None)?; + assert_eq!(resource.mime_type, "blob"); + + // Test invalid mime type defaults to "text" + let resource = Resource::new("file:///test.txt", Some("invalid".to_string()), None)?; + assert_eq!(resource.mime_type, "text"); + + // Test None defaults to "text" + let resource = Resource::new("file:///test.txt", None, None)?; + assert_eq!(resource.mime_type, "text"); + + Ok(()) + } + + #[test] + fn test_with_description() -> Result<()> { + let resource = Resource::with_uri("file:///test.txt", "test.txt", 0.0, None)? + .with_description("A test resource"); + + assert_eq!(resource.description, Some("A test resource".to_string())); + Ok(()) + } + + #[test] + fn test_with_mime_type() -> Result<()> { + let resource = + Resource::with_uri("file:///test.txt", "test.txt", 0.0, None)?.with_mime_type("blob"); + + assert_eq!(resource.mime_type, "blob"); + + // Test invalid mime type defaults to "text" + let resource = resource.with_mime_type("invalid"); + assert_eq!(resource.mime_type, "text"); + Ok(()) + } + + #[test] + fn test_invalid_uri() { + let result = Resource::new("not-a-uri", None, None); + assert!(result.is_err()); + } +} diff --git a/crates/mcp-core/src/role.rs b/crates/mcp-core/src/role.rs new file mode 100644 index 00000000..38f3a872 --- /dev/null +++ b/crates/mcp-core/src/role.rs @@ -0,0 +1,9 @@ +/// Roles to describe the origin/ownership of content +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + User, + Assistant, +} diff --git a/crates/mcp-core/src/tool.rs b/crates/mcp-core/src/tool.rs new file mode 100644 index 00000000..3f6f4246 --- /dev/null +++ b/crates/mcp-core/src/tool.rs @@ -0,0 +1,51 @@ +/// Tools represent a routine that a server can execute +/// Tool calls represent requests from the client to execute one +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// A tool that can be used by a model. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Tool { + /// The name of the tool + pub name: String, + /// A description of what the tool does + pub description: String, + /// A JSON Schema object defining the expected parameters for the tool + pub input_schema: Value, +} + +impl Tool { + /// Create a new tool with the given name and description + pub fn new(name: N, description: D, input_schema: Value) -> Self + where + N: Into, + D: Into, + { + Tool { + name: name.into(), + description: description.into(), + input_schema, + } + } +} + +/// A tool call request that an extension can execute +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCall { + /// The name of the tool to execute + pub name: String, + /// The parameters for the execution + pub arguments: Value, +} + +impl ToolCall { + /// Create a new ToolUse with the given name and parameters + pub fn new>(name: S, arguments: Value) -> Self { + Self { + name: name.into(), + arguments, + } + } +} diff --git a/crates/mcp-macros/Cargo.toml b/crates/mcp-macros/Cargo.toml new file mode 100644 index 00000000..65450f7d --- /dev/null +++ b/crates/mcp-macros/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "mcp-macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0", features = ["full", "extra-traits"] } +quote = "1.0" +proc-macro2 = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +mcp-core = { path = "../mcp-core" } +async-trait = "0.1" +schemars = "0.8" +convert_case = "0.6.0" + +[dev-dependencies] +tokio = { version = "1.0", features = ["full"] } +async-trait = "0.1" +serde_json = "1.0" +schemars = "0.8" diff --git a/crates/mcp-macros/examples/calculator.rs b/crates/mcp-macros/examples/calculator.rs new file mode 100644 index 00000000..64533464 --- /dev/null +++ b/crates/mcp-macros/examples/calculator.rs @@ -0,0 +1,53 @@ +use mcp_core::handler::{ToolError, ToolHandler}; +use mcp_macros::tool; + +#[tokio::main] +async fn main() -> std::result::Result<(), Box> { + // Create an instance of our tool + let calculator = Calculator; + + // Print tool information + println!("Tool name: {}", calculator.name()); + println!("Tool description: {}", calculator.description()); + println!("Tool schema: {}", calculator.schema()); + + // Test the tool with some sample input + let input = serde_json::json!({ + "x": 5, + "y": 3, + "operation": "multiply" + }); + + let result = calculator.call(input).await?; + println!("Result: {}", result); + + Ok(()) +} + +#[tool( + name = "calculator", + description = "Perform basic arithmetic operations", + params( + x = "First number in the calculation", + y = "Second number in the calculation", + operation = "The operation to perform (add, subtract, multiply, divide)" + ) +)] +async fn calculator(x: i32, y: i32, operation: String) -> Result { + match operation.as_str() { + "add" => Ok(x + y), + "subtract" => Ok(x - y), + "multiply" => Ok(x * y), + "divide" => { + if y == 0 { + Err(ToolError::ExecutionError("Division by zero".into())) + } else { + Ok(x / y) + } + } + _ => Err(ToolError::InvalidParameters(format!( + "Unknown operation: {}", + operation + ))), + } +} diff --git a/crates/mcp-macros/src/lib.rs b/crates/mcp-macros/src/lib.rs new file mode 100644 index 00000000..d918d07c --- /dev/null +++ b/crates/mcp-macros/src/lib.rs @@ -0,0 +1,152 @@ +use convert_case::{Case, Casing}; +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use std::collections::HashMap; +use syn::{ + parse::Parse, parse::ParseStream, parse_macro_input, punctuated::Punctuated, Expr, ExprLit, + FnArg, ItemFn, Lit, Meta, Pat, PatType, Token, +}; + +struct MacroArgs { + name: Option, + description: Option, + param_descriptions: HashMap, +} + +impl Parse for MacroArgs { + fn parse(input: ParseStream) -> syn::Result { + let mut name = None; + let mut description = None; + let mut param_descriptions = HashMap::new(); + + let meta_list: Punctuated = Punctuated::parse_terminated(input)?; + + for meta in meta_list { + match meta { + Meta::NameValue(nv) => { + let ident = nv.path.get_ident().unwrap().to_string(); + if let Expr::Lit(ExprLit { + lit: Lit::Str(lit_str), + .. + }) = nv.value + { + match ident.as_str() { + "name" => name = Some(lit_str.value()), + "description" => description = Some(lit_str.value()), + _ => {} + } + } + } + Meta::List(list) if list.path.is_ident("params") => { + let nested: Punctuated = + list.parse_args_with(Punctuated::parse_terminated)?; + + for meta in nested { + if let Meta::NameValue(nv) = meta { + if let Expr::Lit(ExprLit { + lit: Lit::Str(lit_str), + .. + }) = nv.value + { + let param_name = nv.path.get_ident().unwrap().to_string(); + param_descriptions.insert(param_name, lit_str.value()); + } + } + } + } + _ => {} + } + } + + Ok(MacroArgs { + name, + description, + param_descriptions, + }) + } +} + +#[proc_macro_attribute] +pub fn tool(args: TokenStream, input: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as MacroArgs); + let input_fn = parse_macro_input!(input as ItemFn); + + // Extract function details + let fn_name = &input_fn.sig.ident; + let fn_name_str = fn_name.to_string(); + + // Generate PascalCase struct name from the function name + let struct_name = format_ident!("{}", { fn_name_str.to_case(Case::Pascal) }); + + // Use provided name or function name as default + let tool_name = args.name.unwrap_or(fn_name_str); + let tool_description = args.description.unwrap_or_default(); + + // Extract parameter names, types, and descriptions + let mut param_defs = Vec::new(); + let mut param_names = Vec::new(); + + for arg in input_fn.sig.inputs.iter() { + if let FnArg::Typed(PatType { pat, ty, .. }) = arg { + if let Pat::Ident(param_ident) = &**pat { + let param_name = ¶m_ident.ident; + let param_name_str = param_name.to_string(); + let description = args + .param_descriptions + .get(¶m_name_str) + .map(|s| s.as_str()) + .unwrap_or(""); + + param_names.push(param_name); + param_defs.push(quote! { + #[schemars(description = #description)] + #param_name: #ty + }); + } + } + } + + // Generate the implementation + let params_struct_name = format_ident!("{}Parameters", struct_name); + let expanded = quote! { + #[derive(serde::Deserialize, schemars::JsonSchema)] + struct #params_struct_name { + #(#param_defs,)* + } + + #input_fn + + #[derive(Default)] + struct #struct_name; + + #[async_trait::async_trait] + impl mcp_core::handler::ToolHandler for #struct_name { + fn name(&self) -> &'static str { + #tool_name + } + + fn description(&self) -> &'static str { + #tool_description + } + + fn schema(&self) -> serde_json::Value { + mcp_core::handler::generate_schema::<#params_struct_name>() + .expect("Failed to generate schema") + } + + async fn call(&self, params: serde_json::Value) -> Result { + let params: #params_struct_name = serde_json::from_value(params) + .map_err(|e| mcp_core::handler::ToolError::InvalidParameters(e.to_string()))?; + + // Extract parameters and call the function + let result = #fn_name(#(params.#param_names,)*).await + .map_err(|e| mcp_core::handler::ToolError::ExecutionError(e.to_string()))?; + + Ok(serde_json::to_value(result).expect("should serialize")) + + } + } + }; + + TokenStream::from(expanded) +} diff --git a/crates/mcp-server/Cargo.toml b/crates/mcp-server/Cargo.toml new file mode 100644 index 00000000..3657fc58 --- /dev/null +++ b/crates/mcp-server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mcp-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.94" +thiserror = "1.0" +mcp-core = { path = "../mcp-core" } +mcp-macros = { path = "../mcp-macros" } +serde = { version = "1.0.216", features = ["derive"] } +serde_json = "1.0.133" +schemars = "0.8" +tokio = { version = "1", features = ["full"] } +tower = { version = "0.4", features = ["timeout"] } +tower-service = "0.3" +futures = "0.3" +pin-project = "1.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" +async-trait = "0.1" diff --git a/crates/mcp-server/README.md b/crates/mcp-server/README.md new file mode 100644 index 00000000..1e4f0617 --- /dev/null +++ b/crates/mcp-server/README.md @@ -0,0 +1,7 @@ +### Test with MCP Inspector + +```bash +npx @modelcontextprotocol/inspector cargo run -p mcp-server +``` + +Then visit the Inspector in the browser window and test the different endpoints. \ No newline at end of file diff --git a/crates/mcp-server/src/errors.rs b/crates/mcp-server/src/errors.rs new file mode 100644 index 00000000..7ebe2534 --- /dev/null +++ b/crates/mcp-server/src/errors.rs @@ -0,0 +1,104 @@ +use thiserror::Error; + +pub type BoxError = Box; + +#[derive(Error, Debug)] +pub enum TransportError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON serialization error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Invalid UTF-8 sequence: {0}")] + Utf8(#[from] std::string::FromUtf8Error), + + #[error("Protocol error: {0}")] + Protocol(String), + + #[error("Invalid message format: {0}")] + InvalidMessage(String), +} + +#[derive(Error, Debug)] +pub enum ServerError { + #[error("Transport error: {0}")] + Transport(#[from] TransportError), + + #[error("Service error: {0}")] + Service(String), + + #[error("Internal error: {0}")] + Internal(String), + + #[error("Request timed out")] + Timeout(#[from] tower::timeout::error::Elapsed), +} + +#[derive(Error, Debug)] +pub enum RouterError { + #[error("Method not found: {0}")] + MethodNotFound(String), + + #[error("Invalid parameters: {0}")] + InvalidParams(String), + + #[error("Internal error: {0}")] + Internal(String), + + #[error("Tool not found: {0}")] + ToolNotFound(String), + + #[error("Resource not found: {0}")] + ResourceNotFound(String), + + #[error("Not found: {0}")] + PromptNotFound(String), +} + +impl From for mcp_core::protocol::ErrorData { + fn from(err: RouterError) -> Self { + use mcp_core::protocol::*; + match err { + RouterError::MethodNotFound(msg) => ErrorData { + code: METHOD_NOT_FOUND, + message: msg, + data: None, + }, + RouterError::InvalidParams(msg) => ErrorData { + code: INVALID_PARAMS, + message: msg, + data: None, + }, + RouterError::Internal(msg) => ErrorData { + code: INTERNAL_ERROR, + message: msg, + data: None, + }, + RouterError::ToolNotFound(msg) => ErrorData { + code: INVALID_REQUEST, + message: msg, + data: None, + }, + RouterError::ResourceNotFound(msg) => ErrorData { + code: INVALID_REQUEST, + message: msg, + data: None, + }, + RouterError::PromptNotFound(msg) => ErrorData { + code: INVALID_REQUEST, + message: msg, + data: None, + }, + } + } +} + +impl From for RouterError { + fn from(err: mcp_core::handler::ResourceError) -> Self { + match err { + mcp_core::handler::ResourceError::NotFound(msg) => RouterError::ResourceNotFound(msg), + _ => RouterError::Internal("Unknown resource error".to_string()), + } + } +} diff --git a/crates/mcp-server/src/lib.rs b/crates/mcp-server/src/lib.rs new file mode 100644 index 00000000..de636667 --- /dev/null +++ b/crates/mcp-server/src/lib.rs @@ -0,0 +1,264 @@ +use std::{ + pin::Pin, + task::{Context, Poll}, +}; + +use futures::{Future, Stream}; +use mcp_core::protocol::{JsonRpcError, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse}; +use pin_project::pin_project; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tower_service::Service; + +mod errors; +pub use errors::{BoxError, RouterError, ServerError, TransportError}; + +pub mod router; +pub use router::Router; + +/// A transport layer that handles JSON-RPC messages over byte +#[pin_project] +pub struct ByteTransport { + #[pin] + reader: R, + #[pin] + writer: W, +} + +impl ByteTransport +where + R: AsyncRead, + W: AsyncWrite, +{ + pub fn new(reader: R, writer: W) -> Self { + Self { reader, writer } + } +} + +impl Stream for ByteTransport +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut this = self.project(); + let mut buf = Vec::new(); + // Default BufReader capacity is 8 * 1024, increase this to 2MB to the file size limit + // allows the buffer to have the capacity to read very large calls + let mut reader = BufReader::with_capacity(2 * 1024 * 1024, &mut this.reader); + + let mut read_future = Box::pin(reader.read_until(b'\n', &mut buf)); + match read_future.as_mut().poll(cx) { + Poll::Ready(Ok(0)) => Poll::Ready(None), // EOF + Poll::Ready(Ok(_)) => { + // Convert to UTF-8 string + let line = match String::from_utf8(buf) { + Ok(s) => s, + Err(e) => return Poll::Ready(Some(Err(TransportError::Utf8(e)))), + }; + // Log incoming message here before serde conversion to + // track incomplete chunks which are not valid JSON + tracing::info!(json = %line, "incoming message"); + + // Parse JSON and validate message format + match serde_json::from_str::(&line) { + Ok(value) => { + // Validate basic JSON-RPC structure + if !value.is_object() { + return Poll::Ready(Some(Err(TransportError::InvalidMessage( + "Message must be a JSON object".into(), + )))); + } + + let obj = value.as_object().unwrap(); // Safe due to check above + + // Check jsonrpc version field + if !obj.contains_key("jsonrpc") || obj["jsonrpc"] != "2.0" { + return Poll::Ready(Some(Err(TransportError::InvalidMessage( + "Missing or invalid jsonrpc version".into(), + )))); + } + + // Now try to parse as proper message + match serde_json::from_value::(value) { + Ok(msg) => Poll::Ready(Some(Ok(msg))), + Err(e) => Poll::Ready(Some(Err(TransportError::Json(e)))), + } + } + Err(e) => Poll::Ready(Some(Err(TransportError::Json(e)))), + } + } + Poll::Ready(Err(e)) => Poll::Ready(Some(Err(TransportError::Io(e)))), + Poll::Pending => Poll::Pending, + } + } +} + +impl ByteTransport +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + pub async fn write_message(&mut self, msg: JsonRpcMessage) -> Result<(), std::io::Error> { + let json = serde_json::to_string(&msg)?; + Pin::new(&mut self.writer) + .write_all(json.as_bytes()) + .await?; + Pin::new(&mut self.writer).write_all(b"\n").await?; + Pin::new(&mut self.writer).flush().await?; + Ok(()) + } +} + +/// The main server type that processes incoming requests +pub struct Server { + service: S, +} + +impl Server +where + S: Service + Send, + S::Error: Into, + S::Future: Send, +{ + pub fn new(service: S) -> Self { + Self { service } + } + + // TODO transport trait instead of byte transport if we implement others + pub async fn run(self, mut transport: ByteTransport) -> Result<(), ServerError> + where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, + { + use futures::StreamExt; + let mut service = self.service; + + tracing::info!("Server started"); + while let Some(msg_result) = transport.next().await { + let _span = tracing::span!(tracing::Level::INFO, "message_processing").entered(); + match msg_result { + Ok(msg) => { + match msg { + JsonRpcMessage::Request(request) => { + // Serialize request for logging + let id = request.id; + let request_json = serde_json::to_string(&request) + .unwrap_or_else(|_| "Failed to serialize request".to_string()); + + tracing::info!( + request_id = ?id, + method = ?request.method, + json = %request_json, + "Received request" + ); + + // Process the request using our service + let response = match service.call(request).await { + Ok(resp) => resp, + Err(e) => { + let error_msg = e.into().to_string(); + tracing::error!(error = %error_msg, "Request processing failed"); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(mcp_core::protocol::ErrorData { + code: mcp_core::protocol::INTERNAL_ERROR, + message: error_msg, + data: None, + }), + } + } + }; + + // Serialize response for logging + let response_json = serde_json::to_string(&response) + .unwrap_or_else(|_| "Failed to serialize response".to_string()); + + tracing::info!( + response_id = ?response.id, + json = %response_json, + "Sending response" + ); + // Send the response back + if let Err(e) = transport + .write_message(JsonRpcMessage::Response(response)) + .await + { + return Err(ServerError::Transport(TransportError::Io(e))); + } + } + JsonRpcMessage::Response(_) + | JsonRpcMessage::Notification(_) + | JsonRpcMessage::Nil + | JsonRpcMessage::Error(_) => { + // Ignore responses, notifications and nil messages for now + continue; + } + } + } + Err(e) => { + // Convert transport error to JSON-RPC error response + let error = match e { + TransportError::Json(_) | TransportError::InvalidMessage(_) => { + mcp_core::protocol::ErrorData { + code: mcp_core::protocol::PARSE_ERROR, + message: e.to_string(), + data: None, + } + } + TransportError::Protocol(_) => mcp_core::protocol::ErrorData { + code: mcp_core::protocol::INVALID_REQUEST, + message: e.to_string(), + data: None, + }, + _ => mcp_core::protocol::ErrorData { + code: mcp_core::protocol::INTERNAL_ERROR, + message: e.to_string(), + data: None, + }, + }; + + let error_response = JsonRpcMessage::Error(JsonRpcError { + jsonrpc: "2.0".to_string(), + id: None, + error, + }); + + if let Err(e) = transport.write_message(error_response).await { + return Err(ServerError::Transport(TransportError::Io(e))); + } + } + } + } + + Ok(()) + } +} + +// Define a specific service implementation that we need for any +// Any router implements this +pub trait BoundedService: + Service< + JsonRpcRequest, + Response = JsonRpcResponse, + Error = BoxError, + Future = Pin> + Send>>, + > + Send + + 'static +{ +} + +// Implement it for any type that meets the bounds +impl BoundedService for T where + T: Service< + JsonRpcRequest, + Response = JsonRpcResponse, + Error = BoxError, + Future = Pin> + Send>>, + > + Send + + 'static +{ +} diff --git a/crates/mcp-server/src/main.rs b/crates/mcp-server/src/main.rs new file mode 100644 index 00000000..eee25002 --- /dev/null +++ b/crates/mcp-server/src/main.rs @@ -0,0 +1,184 @@ +use anyhow::Result; +use mcp_core::content::Content; +use mcp_core::handler::ResourceError; +use mcp_core::{handler::ToolError, protocol::ServerCapabilities, resource::Resource, tool::Tool}; +use mcp_server::router::{CapabilitiesBuilder, RouterService}; +use mcp_server::{ByteTransport, Router, Server}; +use serde_json::Value; +use std::{future::Future, pin::Pin, sync::Arc}; +use tokio::{ + io::{stdin, stdout}, + sync::Mutex, +}; +use tracing_appender::rolling::{RollingFileAppender, Rotation}; +use tracing_subscriber::{self, EnvFilter}; + +// A simple counter service that demonstrates the Router trait +#[derive(Clone)] +struct CounterRouter { + counter: Arc>, +} + +impl CounterRouter { + fn new() -> Self { + Self { + counter: Arc::new(Mutex::new(0)), + } + } + + async fn increment(&self) -> Result { + let mut counter = self.counter.lock().await; + *counter += 1; + Ok(*counter) + } + + async fn decrement(&self) -> Result { + let mut counter = self.counter.lock().await; + *counter -= 1; + Ok(*counter) + } + + async fn get_value(&self) -> Result { + let counter = self.counter.lock().await; + Ok(*counter) + } + + fn _create_resource_text(&self, uri: &str, name: &str) -> Resource { + Resource::new(uri, Some("text/plain".to_string()), Some(name.to_string())).unwrap() + } +} + +impl Router for CounterRouter { + fn name(&self) -> String { + "counter".to_string() + } + + fn instructions(&self) -> String { + "This server provides a counter tool that can increment and decrement values. The counter starts at 0 and can be modified using the 'increment' and 'decrement' tools. Use 'get_value' to check the current count.".to_string() + } + + fn capabilities(&self) -> ServerCapabilities { + CapabilitiesBuilder::new() + .with_tools(false) + .with_resources(false, false) + .build() + } + + fn list_tools(&self) -> Vec { + vec![ + Tool::new( + "increment".to_string(), + "Increment the counter by 1".to_string(), + serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + ), + Tool::new( + "decrement".to_string(), + "Decrement the counter by 1".to_string(), + serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + ), + Tool::new( + "get_value".to_string(), + "Get the current counter value".to_string(), + serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + ), + ] + } + + fn call_tool( + &self, + tool_name: &str, + _arguments: Value, + ) -> Pin, ToolError>> + Send + 'static>> { + let this = self.clone(); + let tool_name = tool_name.to_string(); + + Box::pin(async move { + match tool_name.as_str() { + "increment" => { + let value = this.increment().await?; + Ok(vec![Content::text(value.to_string())]) + } + "decrement" => { + let value = this.decrement().await?; + Ok(vec![Content::text(value.to_string())]) + } + "get_value" => { + let value = this.get_value().await?; + Ok(vec![Content::text(value.to_string())]) + } + _ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))), + } + }) + } + + fn list_resources(&self) -> Vec { + vec![ + self._create_resource_text("str:////Users/to/some/path/", "cwd"), + self._create_resource_text("memo://insights", "memo-name"), + ] + } + + fn read_resource( + &self, + uri: &str, + ) -> Pin> + Send + 'static>> { + let uri = uri.to_string(); + Box::pin(async move { + match uri.as_str() { + "str:////Users/to/some/path/" => { + let cwd = "/Users/to/some/path/"; + Ok(cwd.to_string()) + } + "memo://insights" => { + let memo = + "Business Intelligence Memo\n\nAnalysis has revealed 5 key insights ..."; + Ok(memo.to_string()) + } + _ => Err(ResourceError::NotFound(format!( + "Resource {} not found", + uri + ))), + } + }) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + // Set up file appender for logging + let file_appender = RollingFileAppender::new(Rotation::DAILY, "logs", "mcp-server.log"); + + // Initialize the tracing subscriber with file and stdout logging + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) + .with_writer(file_appender) + .with_target(false) + .with_thread_ids(true) + .with_file(true) + .with_line_number(true) + .init(); + + tracing::info!("Starting MCP server"); + + // Create an instance of our counter router + let router = RouterService(CounterRouter::new()); + + // Create and run the server + let server = Server::new(router); + let transport = ByteTransport::new(stdin(), stdout()); + + tracing::info!("Server initialized and ready to handle requests"); + Ok(server.run(transport).await?) +} diff --git a/crates/mcp-server/src/router.rs b/crates/mcp-server/src/router.rs new file mode 100644 index 00000000..51815b7d --- /dev/null +++ b/crates/mcp-server/src/router.rs @@ -0,0 +1,435 @@ +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, +}; + +type PromptFuture = Pin> + Send + 'static>>; + +use mcp_core::{ + content::Content, + handler::{PromptError, ResourceError, ToolError}, + prompt::{Prompt, PromptMessage, PromptMessageRole}, + protocol::{ + CallToolResult, GetPromptResult, Implementation, InitializeResult, JsonRpcRequest, + JsonRpcResponse, ListPromptsResult, ListResourcesResult, ListToolsResult, + PromptsCapability, ReadResourceResult, ResourcesCapability, ServerCapabilities, + ToolsCapability, + }, + ResourceContents, +}; +use serde_json::Value; +use tower_service::Service; + +use crate::{BoxError, RouterError}; + +/// Builder for configuring and constructing capabilities +pub struct CapabilitiesBuilder { + tools: Option, + prompts: Option, + resources: Option, +} + +impl Default for CapabilitiesBuilder { + fn default() -> Self { + Self::new() + } +} + +impl CapabilitiesBuilder { + pub fn new() -> Self { + Self { + tools: None, + prompts: None, + resources: None, + } + } + + /// Add multiple tools to the router + pub fn with_tools(mut self, list_changed: bool) -> Self { + self.tools = Some(ToolsCapability { + list_changed: Some(list_changed), + }); + self + } + + /// Enable prompts capability + pub fn with_prompts(mut self, list_changed: bool) -> Self { + self.prompts = Some(PromptsCapability { + list_changed: Some(list_changed), + }); + self + } + + /// Enable resources capability + pub fn with_resources(mut self, subscribe: bool, list_changed: bool) -> Self { + self.resources = Some(ResourcesCapability { + subscribe: Some(subscribe), + list_changed: Some(list_changed), + }); + self + } + + /// Build the router with automatic capability inference + pub fn build(self) -> ServerCapabilities { + // Create capabilities based on what's configured + ServerCapabilities { + tools: self.tools, + prompts: self.prompts, + resources: self.resources, + } + } +} + +pub trait Router: Send + Sync + 'static { + fn name(&self) -> String; + // in the protocol, instructions are optional but we make it required + fn instructions(&self) -> String; + fn capabilities(&self) -> ServerCapabilities; + fn list_tools(&self) -> Vec; + fn call_tool( + &self, + tool_name: &str, + arguments: Value, + ) -> Pin, ToolError>> + Send + 'static>>; + fn list_resources(&self) -> Vec; + fn read_resource( + &self, + uri: &str, + ) -> Pin> + Send + 'static>>; + fn list_prompts(&self) -> Option> { + None + } + fn get_prompt(&self, _prompt_name: &str) -> Option { + None + } + + // Helper method to create base response + fn create_response(&self, id: Option) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: None, + } + } + + fn handle_initialize( + &self, + req: JsonRpcRequest, + ) -> impl Future> + Send { + async move { + let result = InitializeResult { + protocol_version: "2024-11-05".to_string(), + capabilities: self.capabilities().clone(), + server_info: Implementation { + name: self.name(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + instructions: Some(self.instructions()), + }; + + let mut response = self.create_response(req.id); + response.result = + Some(serde_json::to_value(result).map_err(|e| { + RouterError::Internal(format!("JSON serialization error: {}", e)) + })?); + + Ok(response) + } + } + + fn handle_tools_list( + &self, + req: JsonRpcRequest, + ) -> impl Future> + Send { + async move { + let tools = self.list_tools(); + + let result = ListToolsResult { + tools, + next_cursor: None, + }; + let mut response = self.create_response(req.id); + response.result = + Some(serde_json::to_value(result).map_err(|e| { + RouterError::Internal(format!("JSON serialization error: {}", e)) + })?); + + Ok(response) + } + } + + fn handle_tools_call( + &self, + req: JsonRpcRequest, + ) -> impl Future> + Send { + async move { + let params = req + .params + .ok_or_else(|| RouterError::InvalidParams("Missing parameters".into()))?; + + let name = params + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| RouterError::InvalidParams("Missing tool name".into()))?; + + let arguments = params.get("arguments").cloned().unwrap_or(Value::Null); + + let result = match self.call_tool(name, arguments).await { + Ok(result) => CallToolResult { + content: result, + is_error: None, + }, + Err(err) => CallToolResult { + content: vec![Content::text(err.to_string())], + is_error: Some(true), + }, + }; + + let mut response = self.create_response(req.id); + response.result = + Some(serde_json::to_value(result).map_err(|e| { + RouterError::Internal(format!("JSON serialization error: {}", e)) + })?); + + Ok(response) + } + } + + fn handle_resources_list( + &self, + req: JsonRpcRequest, + ) -> impl Future> + Send { + async move { + let resources = self.list_resources(); + + let result = ListResourcesResult { + resources, + next_cursor: None, + }; + let mut response = self.create_response(req.id); + response.result = + Some(serde_json::to_value(result).map_err(|e| { + RouterError::Internal(format!("JSON serialization error: {}", e)) + })?); + + Ok(response) + } + } + + fn handle_resources_read( + &self, + req: JsonRpcRequest, + ) -> impl Future> + Send { + async move { + let params = req + .params + .ok_or_else(|| RouterError::InvalidParams("Missing parameters".into()))?; + + let uri = params + .get("uri") + .and_then(Value::as_str) + .ok_or_else(|| RouterError::InvalidParams("Missing resource URI".into()))?; + + let contents = self.read_resource(uri).await.map_err(RouterError::from)?; + + let result = ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri: uri.to_string(), + mime_type: Some("text/plain".to_string()), + text: contents, + }], + }; + + let mut response = self.create_response(req.id); + response.result = + Some(serde_json::to_value(result).map_err(|e| { + RouterError::Internal(format!("JSON serialization error: {}", e)) + })?); + + Ok(response) + } + } + + fn handle_prompts_list( + &self, + req: JsonRpcRequest, + ) -> impl Future> + Send { + async move { + let prompts = self.list_prompts().unwrap_or_default(); + + let result = ListPromptsResult { prompts }; + + let mut response = self.create_response(req.id); + response.result = + Some(serde_json::to_value(result).map_err(|e| { + RouterError::Internal(format!("JSON serialization error: {}", e)) + })?); + + Ok(response) + } + } + + fn handle_prompts_get( + &self, + req: JsonRpcRequest, + ) -> impl Future> + Send { + async move { + // Validate and extract parameters + let params = req + .params + .ok_or_else(|| RouterError::InvalidParams("Missing parameters".into()))?; + + // Extract "name" field + let prompt_name = params + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| RouterError::InvalidParams("Missing prompt name".into()))?; + + // Extract "arguments" field + let arguments = params + .get("arguments") + .and_then(Value::as_object) + .ok_or_else(|| RouterError::InvalidParams("Missing arguments object".into()))?; + + // Fetch the prompt definition first + let prompt = match self.list_prompts() { + Some(prompts) => prompts + .into_iter() + .find(|p| p.name == prompt_name) + .ok_or_else(|| { + RouterError::PromptNotFound(format!("Prompt '{}' not found", prompt_name)) + })?, + None => return Err(RouterError::PromptNotFound("No prompts available".into())), + }; + + // Validate required arguments + for arg in &prompt.arguments { + if arg.required + && (!arguments.contains_key(&arg.name) + || arguments + .get(&arg.name) + .and_then(Value::as_str) + .map_or(true, str::is_empty)) + { + return Err(RouterError::InvalidParams(format!( + "Missing required argument: '{}'", + arg.name + ))); + } + } + + // Now get the prompt content + let description = self + .get_prompt(prompt_name) + .ok_or_else(|| RouterError::PromptNotFound("Prompt not found".into()))? + .await + .map_err(|e| RouterError::Internal(e.to_string()))?; + + // Validate prompt arguments for potential security issues from user text input + // Checks: + // - Prompt must be less than 10000 total characters + // - Argument keys must be less than 1000 characters + // - Argument values must be less than 1000 characters + // - Dangerous patterns, eg "../", "//", "\\\\", "|.*?|<[^>]+>", - "", - result, - flags=re.DOTALL, - ) # Remove head, script, and style tags/content, then any other tags - with open(tmp_text_file_path, "w") as text_file: - text_file.write(plain_text) - return {"html_file_path": tmp_file.name, "text_file_path": tmp_text_file_path} - except httpx.HTTPStatusError as exc: - self.notifier.log(f"Failed fetching with HTTP error: {exc.response.status_code}") - except Exception as exc: - self.notifier.log(f"Failed fetching with error: {str(exc)}") diff --git a/src/goose/synopsis/util.py b/src/goose/synopsis/util.py deleted file mode 100644 index 3f73dd4e..00000000 --- a/src/goose/synopsis/util.py +++ /dev/null @@ -1,11 +0,0 @@ -from goose.notifier import Notifier -from goose.toolkit.utils import RULEPREFIX, RULESTYLE -from rich.markdown import Markdown -from rich.rule import Rule - - -def log_command(notifier: Notifier, command: str, path: str, title: str = "shell") -> None: - notifier.log("") - notifier.log(Rule(RULEPREFIX + f"{title} | [dim magenta]{path}[/]", style=RULESTYLE, align="left")) - notifier.log(Markdown(f"```bash\n{command}\n```")) - notifier.log("") diff --git a/src/goose/system.jinja b/src/goose/system.jinja deleted file mode 100644 index e5647a43..00000000 --- a/src/goose/system.jinja +++ /dev/null @@ -1 +0,0 @@ -You are an AI assistant named Goose. You solve problems using your tools. diff --git a/src/goose/toolkit/__init__.py b/src/goose/toolkit/__init__.py deleted file mode 100644 index fc561ee6..00000000 --- a/src/goose/toolkit/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from functools import cache -from exchange.invalid_choice_error import InvalidChoiceError -from goose.toolkit.base import Toolkit -from goose.utils import load_plugins - - -@cache -def get_toolkit(name: str) -> type[Toolkit]: - toolkits = load_plugins(group="goose.toolkit") - if name not in toolkits: - raise InvalidChoiceError("toolkit", name, toolkits.keys()) - return toolkits[name] diff --git a/src/goose/toolkit/base.py b/src/goose/toolkit/base.py deleted file mode 100644 index d6c232fb..00000000 --- a/src/goose/toolkit/base.py +++ /dev/null @@ -1,65 +0,0 @@ -import inspect -from abc import ABC -from typing import Mapping, Optional, TypeVar - -from attrs import define, field -from exchange import Tool - -from goose.notifier import Notifier - -# Create a type variable that can represent any function signature -F = TypeVar("F", bound=callable) - - -def tool(func: F) -> F: - func._is_tool = True - return func - - -@define -class Requirements: - """A collection of requirements for advanced toolkits - - Requirements are an advanced use case, most toolkits will not need to - use these. They allow one toolkit to interact with another's state. - """ - - _toolkit: str - _requirements: Mapping[str, "Toolkit"] = field(factory=dict) - - def get(self, requirement: str) -> "Toolkit": - """Get a requirement by name.""" - if requirement not in self._requirements: - raise RuntimeError( - f"The toolkit '{self._toolkit}' requested a requirement '{requirement}' but none was passed!\n" - + f" Make sure to include `requires: {{{requirement}: ...}}` in your profile config\n" - + f" See the documentation for {self._toolkit} for more details" - ) - return self._requirements[requirement] - - -class Toolkit(ABC): - """A collection of tools with corresponding prompting - - This class defines the interface that all toolkit implementations must follow, - providing a system prompt and a collection of tools. Both are allowed to be - empty if they are not required for the toolkit. - """ - - def __init__(self, notifier: Notifier, requires: Optional[Requirements] = None) -> None: - self.notifier = notifier - # This needs to be updated after the fact via build_exchange - self.exchange_view = None - - def system(self) -> str: - """Get the addition to the system prompt for this toolkit.""" - return "" - - def tools(self) -> tuple[Tool, ...]: - """Get the tools for this toolkit - - This default method looks for functions on the toolkit annotated - with @tool. - """ - candidates = inspect.getmembers(self, predicate=inspect.ismethod) - return (Tool.from_function(candidate) for _, candidate in candidates if getattr(candidate, "_is_tool", None)) diff --git a/src/goose/toolkit/developer.py b/src/goose/toolkit/developer.py deleted file mode 100644 index adba64a0..00000000 --- a/src/goose/toolkit/developer.py +++ /dev/null @@ -1,221 +0,0 @@ -import os -import re -import tempfile -import httpx - -from pathlib import Path - -from exchange import Message -from goose.toolkit.base import Toolkit, tool -from goose.toolkit.utils import get_language, RULEPREFIX, RULESTYLE -from goose.utils.goosehints import fetch_goosehints -from goose.utils.shell import shell -from rich.markdown import Markdown -from rich.table import Table -from rich.rule import Rule - - -class Developer(Toolkit): - """Provides a set of general purpose development capabilities - - The tools include plan management, a general purpose shell execution tool, and file operations. - We also include some default shell strategies in the prompt, such as using ripgrep - """ - - def __init__(self, *args: object, **kwargs: dict[str, object]) -> None: - super().__init__(*args, **kwargs) - self.timestamps: dict[str, float] = {} - self.cwd = os.getcwd() - - def system(self) -> str: - """Retrieve system configuration details for developer""" - system_prompt = Message.load("prompts/developer.jinja").text - hints = fetch_goosehints() - - if hints: - system_prompt = f"{system_prompt}\n\nHints:\n{hints}" - return system_prompt - - @tool - def update_plan(self, tasks: list[dict]) -> list[dict]: - """ - Update the plan by overwriting all current tasks - - This can be used to update the status of a task. This update will be - shown to the user directly, you do not need to reiterate it - - Args: - tasks (list(dict)): The list of tasks, where each task is a dictionary - with a key for the task "description" and the task "status". The status - MUST be one of "planned", "complete", "failed", "in-progress". - - """ - # Validate the status of each task to ensure it is one of the accepted values. - for task in tasks: - if task["status"] not in {"planned", "complete", "failed", "in-progress"}: - raise ValueError(f"Invalid task status: {task['status']}") - - # Create a table with columns for the index, description, and status of each task. - table = Table(expand=True) - table.add_column("#", justify="right", style="magenta") - table.add_column("Task", justify="left") - table.add_column("Status", justify="left") - - # Mapping of statuses to emojis for better visual representation in the table. - emoji = {"planned": "⏳", "complete": "βœ…", "failed": "❌", "in-progress": "πŸ•‘"} - for i, entry in enumerate(tasks): - table.add_row(str(i), entry["description"], emoji[entry["status"]]) - - # Log the table to display it directly to the user - # `.log` method is used here to log the command execution in the application's UX - self.notifier.log(table) - - # Return the tasks unchanged as the function's primary purpose is to update and display the task status. - return tasks - - @tool - def fetch_web_content(self, url: str) -> str: - """ - Fetch content from a URL using httpx. - - Args: - url (str): url of the site to visit. - Returns: - (dict): A dictionary with two keys: - - 'html_file_path' (str): Path to a html file which has the content of the page. It will be very large so use rg to search it or head in chunks. Will contain meta data and links and markup. - - 'text_file_path' (str): Path to a plain text file which has the some of the content of the page. It will be large so use rg to search it or head in chunks. If content isn't there, try the html variant. - """ # noqa - friendly_name = re.sub(r"[^a-zA-Z0-9]", "_", url)[:50] # Limit length to prevent filenames from being too long - - try: - result = httpx.get(url, follow_redirects=True).text - with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=f"_{friendly_name}.html") as tmp_file: - tmp_file.write(result) - tmp_text_file_path = tmp_file.name.replace(".html", ".txt") - plain_text = re.sub( - r".*?|.*?|.*?|<[^>]+>", - "", - result, - flags=re.DOTALL, - ) # Remove head, script, and style tags/content, then any other tags - with open(tmp_text_file_path, "w") as text_file: - text_file.write(plain_text) - return {"html_file_path": tmp_file.name, "text_file_path": tmp_text_file_path} - except httpx.HTTPStatusError as exc: - self.notifier.log(f"Failed fetching with HTTP error: {exc.response.status_code}") - except Exception as exc: - self.notifier.log(f"Failed fetching with error: {str(exc)}") - - @tool - def patch_file(self, path: str, before: str, after: str) -> str: - """Patch the file at the specified by replacing before with after - - Before **must** be present exactly once in the file, so that it can safely - be replaced with after. - - Args: - path (str): The path to the file, in the format "path/to/file.txt" - before (str): The content that will be replaced - after (str): The content it will be replaced with - """ - self.notifier.status(f"editing {path}") - _path = Path(path) - language = get_language(path) - - content = _path.read_text() - - if content.count(before) > 1: - raise ValueError("The before content is present multiple times in the file, be more specific.") - if content.count(before) < 1: - raise ValueError("The before content was not found in file, be careful that you recreate it exactly.") - - content = content.replace(before, after) - _path.write_text(content) - - output = f""" -```{language} -{before} -``` --> -```{language} -{after} -``` -""" - self.notifier.log(Rule(RULEPREFIX + path, style=RULESTYLE, align="left")) - self.notifier.log(Markdown(output)) - return "Succesfully replaced before with after." - - @tool - def read_file(self, path: str) -> str: - """Read the content of the file at path - - Args: - path (str): The destination file path, in the format "path/to/file.txt" - """ - language = get_language(path) - content = Path(path).expanduser().read_text() - self.notifier.log(Markdown(f"```\ncat {path}\n```")) - # Record the last read timestamp - self.timestamps[path] = os.path.getmtime(path) - return f"```{language}\n{content}\n```" - - @tool - def shell(self, command: str) -> str: - """ - Execute a command on the shell - - This will return the output and error concatenated into a single string, as - you would see from running on the command line. There will also be an indication - of if the command succeeded or failed. - - Args: - command (str): The shell command to run. It can support multiline statements - if you need to run more than one at a time - """ - # Log the command being executed in a visually structured format (Markdown). - self.notifier.log(Rule(RULEPREFIX + "shell", style=RULESTYLE, align="left")) - self.notifier.log(Markdown(f"```bash\n{command}\n```")) - return shell(command, self.notifier, self.exchange_view) - - @tool - def write_file(self, path: str, content: str) -> str: - """ - Write a file at the specified path with the provided content. This will create any directories if they do not exist. - The content will fully overwrite the existing file. - - Args: - path (str): The destination file path, in the format "path/to/file.txt" - content (str): The raw file content. - """ # noqa: E501 - self.notifier.status("writing file") - # Get the programming language for syntax highlighting in logs - language = get_language(path) - md = f"```{language}\n{content}\n```" - - # Log the content that will be written to the file - # .log` method is used here to log the command execution in the application's UX - # this method is dynamically attached to functions in the Goose framework - self.notifier.log(Rule(RULEPREFIX + path, style=RULESTYLE, align="left")) - self.notifier.log(Markdown(md)) - - _path = Path(path) - if path in self.timestamps: - last_read_timestamp = self.timestamps.get(path, 0.0) - current_timestamp = os.path.getmtime(path) - if current_timestamp > last_read_timestamp: - raise RuntimeError( - f"File '{path}' has been modified since it was last read." - + " Read the file to incorporate changes or update your plan." - ) - - # Prepare the path and create any necessary parent directories - _path.parent.mkdir(parents=True, exist_ok=True) - - # Write the content to the file - _path.write_text(content) - - # Update the last read timestamp after writing to the file - self.timestamps[path] = os.path.getmtime(path) - - # Return a success message - return f"Successfully wrote to {path}" diff --git a/src/goose/toolkit/github.py b/src/goose/toolkit/github.py deleted file mode 100644 index 4a702592..00000000 --- a/src/goose/toolkit/github.py +++ /dev/null @@ -1,11 +0,0 @@ -from exchange import Message - -from goose.toolkit.base import Toolkit - - -class Github(Toolkit): - """Provides an additional prompt on how to interact with Github""" - - def system(self) -> str: - """Retrieve detailed configuration and procedural guidelines for GitHub operations""" - return Message.load("prompts/github.jinja").text diff --git a/src/goose/toolkit/google_workspace.py b/src/goose/toolkit/google_workspace.py deleted file mode 100644 index 8cc8101c..00000000 --- a/src/goose/toolkit/google_workspace.py +++ /dev/null @@ -1,103 +0,0 @@ -import os - -from exchange import Message # type: ignore - -from goose.toolkit.base import Toolkit, tool -from goose.tools.gmail_client import GmailClient -from goose.tools.google_calendar_client import GoogleCalendarClient -from goose.tools.google_oauth_handler import GoogleOAuthHandler - -SCOPES = ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/calendar.readonly"] - - -def get_file_paths() -> dict[str, str]: - return { - "CLIENT_SECRETS_FILE": os.path.expanduser("~/.config/goose/google_credentials.json"), - "TOKEN_FILE": os.path.expanduser("~/.config/goose/google_oauth_token.json"), - } - - -class GoogleWorkspace(Toolkit): - """A toolkit for integrating with Google APIs""" - - def system(self) -> str: - """Retrieve detailed configuration and procedural guidelines for Jira operations""" - template_content = Message.load("prompts/google_workspace.jinja").text - return template_content - - def login(self) -> str: - try: - file_paths = get_file_paths() - oauth_handler = GoogleOAuthHandler(file_paths["CLIENT_SECRETS_FILE"], file_paths["TOKEN_FILE"], SCOPES) - credentials = oauth_handler.get_credentials() - return f"Successfully authenticated with Google! Access token: {credentials.token[:8]}..." - except Exception as e: - return f"Error: {str(e)}" - - @tool - def list_emails(self) -> str: - """List the emails in the user's Gmail inbox, including email IDs""" - try: - file_paths = get_file_paths() - oauth_handler = GoogleOAuthHandler(file_paths["CLIENT_SECRETS_FILE"], file_paths["TOKEN_FILE"], SCOPES) - credentials = oauth_handler.get_credentials() - gmail_client = GmailClient(credentials) - emails = gmail_client.list_emails() - return emails - except ValueError as e: - return f"Error: {str(e)}" - except Exception as e: - return f"An unexpected error occurred: {str(e)}" - - @tool - def get_email_content(self, email_id: str) -> str: - """ - Get the contents of a single email by its ID. - - Args: - email_id (str): The ID of the email to retrieve. - - Returns: - response (str): The contents of the email, including subject, sender, and body. - """ - try: - file_paths = get_file_paths() - oauth_handler = GoogleOAuthHandler(file_paths["CLIENT_SECRETS_FILE"], file_paths["TOKEN_FILE"], SCOPES) - credentials = oauth_handler.get_credentials() - gmail_client = GmailClient(credentials) - email_content = gmail_client.get_email_content(email_id) - return email_content - except ValueError as e: - return f"Error: {str(e)}" - except Exception as e: - return f"An unexpected error occurred: {str(e)}" - - @tool - def todays_schedule(self) -> str: - """List the events on the user's Google Calendar for today""" - try: - file_paths = get_file_paths() - oauth_handler = GoogleOAuthHandler(file_paths["CLIENT_SECRETS_FILE"], file_paths["TOKEN_FILE"], SCOPES) - credentials = oauth_handler.get_credentials() - calendar_client = GoogleCalendarClient(credentials) - schedule = calendar_client.list_events_for_today() - return schedule - except ValueError as e: - return f"Error: {str(e)}" - except Exception as e: - return f"An unexpected error occurred: {str(e)}" - - @tool - def list_calendars(self) -> str: - """List the calendars in the user's Google Calendar""" - try: - file_paths = get_file_paths() - oauth_handler = GoogleOAuthHandler(file_paths["CLIENT_SECRETS_FILE"], file_paths["TOKEN_FILE"], SCOPES) - credentials = oauth_handler.get_credentials() - calendar_client = GoogleCalendarClient(credentials) - calendars = calendar_client.list_calendars() - return calendars - except ValueError as e: - return f"Error: {str(e)}" - except Exception as e: - return f"An unexpected error occurred: {str(e)}" diff --git a/src/goose/toolkit/jira.py b/src/goose/toolkit/jira.py deleted file mode 100644 index a6cda8b6..00000000 --- a/src/goose/toolkit/jira.py +++ /dev/null @@ -1,26 +0,0 @@ -from exchange import Message # type: ignore -from goose.toolkit.base import tool # type: ignore -import re -from goose.toolkit.base import Toolkit - - -class Jira(Toolkit): - """Provides an additional prompt on how to interact with Jira""" - - def system(self) -> str: - """Retrieve detailed configuration and procedural guidelines for Jira operations""" - template_content = Message.load("prompts/jira.jinja").text - return template_content - - @tool - def is_jira_issue(self, issue_key: str) -> str: - """ - Checks if a given string is a valid JIRA issue key. - Use this if it looks like the user is asking about a JIRA issue. - - Args: - issue_key (str): The potential Jira issue key to be validated. - - """ - pattern = r"[A-Z]+-\d+" - return bool(re.match(pattern, issue_key)) diff --git a/src/goose/toolkit/lint.py b/src/goose/toolkit/lint.py deleted file mode 100644 index 6b30c28d..00000000 --- a/src/goose/toolkit/lint.py +++ /dev/null @@ -1,23 +0,0 @@ -from goose.utils import load_plugins - - -def lint_toolkits() -> None: - for toolkit_name, toolkit in load_plugins("goose.toolkit").items(): - assert toolkit.__doc__ is not None, f"`{toolkit_name}` toolkit must have a docstring" - first_line_of_docstring = toolkit.__doc__.split("\n")[0] - assert len(first_line_of_docstring.split(" ")) > 5, f"`{toolkit_name}` toolkit docstring is too short" - assert len(first_line_of_docstring.split(" ")) < 12, f"`{toolkit_name}` toolkit docstring is too long" - assert first_line_of_docstring[0].isupper(), ( - f"`{toolkit_name}` toolkit docstring must start with a capital letter" - ) - - -def lint_providers() -> None: - for provider_name, provider in load_plugins(group="exchange.provider").items(): - assert provider.__doc__ is not None, f"`{provider_name}` provider must have a docstring" - first_line_of_docstring = provider.__doc__.split("\n")[0] - assert len(first_line_of_docstring.split(" ")) > 5, f"`{provider_name}` provider docstring is too short" - assert len(first_line_of_docstring.split(" ")) < 20, f"`{provider_name}` provider docstring is too long" - assert first_line_of_docstring[0].isupper(), ( - f"`{provider_name}` provider docstring must start with a capital letter" - ) diff --git a/src/goose/toolkit/memory.py b/src/goose/toolkit/memory.py deleted file mode 100644 index d1ee615b..00000000 --- a/src/goose/toolkit/memory.py +++ /dev/null @@ -1,207 +0,0 @@ -from pathlib import Path -from typing import Optional, List, Dict -import re - -from jinja2 import Environment, FileSystemLoader - -from goose.toolkit.base import Toolkit, tool - - -class Memory(Toolkit): - """Memory toolkit for storing and retrieving natural - language memories with categories and tags""" - - def __init__(self, *args: object, **kwargs: dict[str, object]) -> None: - super().__init__(*args, **kwargs) - # Setup memory directories - self.local_memory_dir = Path(".goose/memory") - self.global_memory_dir = Path.home() / ".config/goose/memory" - self._ensure_memory_dirs() - - def _get_memories_data(self) -> dict: - """Get memory data in a format suitable for template rendering""" - data = {"global": {}, "local": {}, "has_memories": False} - - # Get global memories - if self.global_memory_dir.exists(): - global_cats = [f.stem for f in self.global_memory_dir.glob("*.txt")] - for cat in sorted(global_cats): - memories = self._load_memories(cat, "global") - if memories: - data["global"][cat] = memories - data["has_memories"] = True - - # Get local memories - if self.local_memory_dir.exists(): - local_cats = [f.stem for f in self.local_memory_dir.glob("*.txt")] - for cat in sorted(local_cats): - memories = self._load_memories(cat, "local") - if memories: - data["local"][cat] = memories - data["has_memories"] = True - - return data - - def system(self) -> str: - """Get the memory-specific additions to the system prompt""" - # Get the template directly since we need to render with our own variables - base_path = Path(__file__).parent / "prompts" - env = Environment(loader=FileSystemLoader(base_path)) - template = env.get_template("memory.jinja") - return template.render(memories=self._get_memories_data()) - - def _ensure_memory_dirs(self) -> None: - """Ensure memory directories exist""" - self.local_memory_dir.parent.mkdir(parents=True, exist_ok=True) - self.local_memory_dir.mkdir(exist_ok=True) - self.global_memory_dir.parent.mkdir(parents=True, exist_ok=True) - self.global_memory_dir.mkdir(exist_ok=True) - - def _get_memory_file(self, category: str, scope: str = "global") -> Path: - """Get the path to a memory category file""" - base_dir = self.global_memory_dir if scope == "global" else self.local_memory_dir - return base_dir / f"{category}.txt" - - def _load_memories(self, category: str, scope: str = "global") -> List[Dict[str, str]]: - """Load memories from a category file""" - memory_file = self._get_memory_file(category, scope) - if not memory_file.exists(): - return [] - - memories = [] - content = memory_file.read_text().strip() - if content: - for block in content.split("\n\n"): - if not block.strip(): - continue - memory_lines = block.strip().split("\n") - tags = [] - text = [] - for line in memory_lines: - if line.startswith("#"): - tags.extend(tag.strip() for tag in line[1:].split()) - else: - text.append(line) - memories.append({"text": "\n".join(text).strip(), "tags": tags}) - return memories - - def _save_memories(self, memories: List[Dict[str, str]], category: str, scope: str = "global") -> None: - """Save memories to a category file""" - memory_file = self._get_memory_file(category, scope) - content = [] - for memory in memories: - if memory["tags"]: - content.append(f"#{' '.join(memory['tags'])}") - content.append(memory["text"]) - content.append("") # Empty line between memories - memory_file.write_text("\n".join(content)) - - @tool - def remember(self, text: str, category: str, tags: Optional[str] = None, scope: str = "global") -> str: - """Save a memory with optional tags in a specific category - - Args: - text (str): The memory text to store - category (str): The category to store the memory under (e.g., development, personal) - tags (str, optional): Space-separated tags to associate with the memory - scope (str): Where to store the memory - 'global' or 'local' - """ - # Clean and validate category name - category = re.sub(r"[^a-zA-Z0-9_-]", "_", category.lower()) - - # Process tags - remove any existing # prefix and store clean tags - tag_list = [] - if tags: - tag_list = [tag.strip().lstrip("#") for tag in tags.split() if tag.strip()] - - # Load existing memories - memories = self._load_memories(category, scope) - - # Add new memory - memories.append({"text": text, "tags": tag_list}) - - # Save updated memories - self._save_memories(memories, category, scope) - - tag_msg = f" with tags: {', '.join(tag_list)}" if tag_list else "" - return f"I'll remember that in the {category} category{tag_msg} ({scope} scope)" - - @tool - def search(self, query: str, category: Optional[str] = None, scope: Optional[str] = None) -> str: - """Search through memories by text and tags - - Args: - query (str): Text to search for in memories and tags - category (str, optional): Specific category to search in - scope (str, optional): Which scope to search - 'global', 'local', or None (both) - """ - results = [] - scopes = ["global", "local"] if scope is None else [scope] - - for current_scope in scopes: - base_dir = self.global_memory_dir if current_scope == "global" else self.local_memory_dir - if not base_dir.exists(): - continue - - # Get categories to search - if category: - categories = [category] - else: - categories = [f.stem for f in base_dir.glob("*.txt")] - - # Search in each category - for cat in categories: - memories = self._load_memories(cat, current_scope) - for memory in memories: - # Search in text and tags - if query.lower() in memory["text"].lower() or any( - query.lower() in tag.lower() for tag in memory["tags"] - ): - tag_str = f" [tags: {', '.join(memory['tags'])}]" if memory["tags"] else "" - results.append(f"{current_scope}/{cat}: {memory['text']}{tag_str}") - - if not results: - return "No matching memories found" - - return "\n\n".join(results) - - @tool - def list_categories(self, scope: Optional[str] = None) -> str: - """List all memory categories - - Args: - scope (str, optional): Which scope to list - 'global', 'local', or None (both) - """ - categories = [] - - if scope in (None, "local") and self.local_memory_dir.exists(): - local_cats = [f.stem for f in self.local_memory_dir.glob("*.txt")] - if local_cats: - categories.append("Local categories:") - categories.extend(f" - {cat}" for cat in sorted(local_cats)) - - if scope in (None, "global") and self.global_memory_dir.exists(): - global_cats = [f.stem for f in self.global_memory_dir.glob("*.txt")] - if global_cats: - categories.append("Global categories:") - categories.extend(f" - {cat}" for cat in sorted(global_cats)) - - if not categories: - return "No categories found in the specified scope(s)" - - return "\n".join(categories) - - @tool - def forget_category(self, category: str, scope: str = "global") -> str: - """Remove an entire category of memories - - Args: - category (str): The category to remove - scope (str): Which scope to remove from - 'global' or 'local' - """ - memory_file = self._get_memory_file(category, scope) - if not memory_file.exists(): - return f"No {category} category found in {scope} scope" - - memory_file.unlink() - return f"Successfully removed {category} category from {scope} scope" diff --git a/src/goose/toolkit/prompts/browser.jinja b/src/goose/toolkit/prompts/browser.jinja deleted file mode 100644 index 4f322999..00000000 --- a/src/goose/toolkit/prompts/browser.jinja +++ /dev/null @@ -1,41 +0,0 @@ -BrowserToolkit is a selenium-based toolset for automated web interactions. -This is useful when the best way to load content, or run a search, perform an action as a user on a page, test a page fill out a form etc requires a real browser to render, run javascript etc. - -You should keep the browser open if needed, as the user may be able to log in and interact to help out if you ask. - -Requests could include: -* searching for an item using a websites search feature -* filling out a form -* reading content -* testing a page or viewing a page -* accessing social media (in which case you check user can log in) -* performing a web search - - -You will use combinations of these tools to take the relevant actions to satisfy the user's requests: - -- **navigate_to(url: str)**: Load and navigate to the specified URL in the web driver. The tool ensures the page has fully loaded before proceeding. - -- **get_html_content()**: Extract the HTML content of the current page and store it in a cached file. Use this to retrieve the latest cache file for offline HTML analysis. - -- **type_into_input(selector: str, text: str, click_enter: False, click_tab: False)**: Type specified text into an input element located by a CSS selector. Simulates human typing for natural input - -- **click_element(selector: str)**: Click an element (button/link) identified by a CSS selector. Use this to interact with webpage elements directly. - -- **find_element_by_text_soup(text: str, filename: str)**: Search for an element containing specific text using BeautifulSoup, sourcing from the cached HTML file. Useful for text-based element queries. - -- **take_browser_screenshot(filename: str)**: Capture a screenshot of the current browser window and save it to a file. Use this for visual verification. - -- **find_elements_of_type(tag_type: str, filename: str)**: Find all elements of a specific HTML tag type using BeautifulSoup, sourcing from the cached HTML file. Useful for retrieving multiple elements of the same type. - -### Important Note on Element Selection: - -When using tools that require CSS selectors or text identification, ensure that: - -1. **Precision**: Selectors must be accurate and precise. The specificity of CSS selectors should match the target element precisely to avoid selection errors. - -2. **DOM Considerations**: Some elements may reside within shadow DOMs, requiring special handling using tools like PyShadow, or may not be visible in the default DOM structure. - -3. **Element Types**: Elements may not always be of the expected type or have attributes you're searching for. Consider the tree structure and hierarchy when querying elements. - -This toolkit facilitates browser automation by scripting user interactions and processing web content efficiently. diff --git a/src/goose/toolkit/prompts/developer.jinja b/src/goose/toolkit/prompts/developer.jinja deleted file mode 100644 index 16e7605f..00000000 --- a/src/goose/toolkit/prompts/developer.jinja +++ /dev/null @@ -1,32 +0,0 @@ -Your role is a developer agent. You build software and solve problems by editing files and -running commands on the shell. - -You can use the shell tool to run any command that would work on the relevant operating system. - -You are an expert with ripgrep - `rg`. When you need to locate content in the code base, use -`rg` exclusively. It will respect ignored files for efficiency. - -To locate files by name, use - -```bash -rg --files | rg example.py -``` - -To locate content inside files, use -```bash -rg 'class Example' -``` - - -If you need to manipulate files, use either the write_file tool or the patch tool. -Make sure to read existing content before attempting to edit. - -The write file tool will do a full overwrite of the existing file, while the patch tool -will edit it using a find and replace. Choose the tool which will make the edit as simple -as possible to execute. - - -# Instructions - -You'll receive a summary and a plan, and can immediately start using your tools and can directly -reply to the user as needed. diff --git a/src/goose/toolkit/prompts/github.jinja b/src/goose/toolkit/prompts/github.jinja deleted file mode 100644 index 8c7ad0f0..00000000 --- a/src/goose/toolkit/prompts/github.jinja +++ /dev/null @@ -1,18 +0,0 @@ -You can interact through github via the `gh` command line generally. -If it fails to auth, prompt the user to run `gh auth login` - -Typically when someone requests you to look at a pull request review, they mean to view -not just the top level comments and reviews, but also the comments nested within that review. - -To do that, you need to first use the API to get reviews: -```bash -gh api -H "Accept: application/vnd.github+json" /repos/OWNER/REPO/pulls/PULL_NUMBER/reviews -``` - -And then for each individual review, get all of the comments: -```bash -gh api -H 'Accept: application/vnd.github+json' /repos/OWNER/REPO/pulls/PULL_NUMBER/reviews/ID/comments -``` - -When you work with a pull request review, use the above approach as well as `gh diff` -to get the full details before answering questions. diff --git a/src/goose/toolkit/prompts/google_workspace.jinja b/src/goose/toolkit/prompts/google_workspace.jinja deleted file mode 100644 index 602963d4..00000000 --- a/src/goose/toolkit/prompts/google_workspace.jinja +++ /dev/null @@ -1,3 +0,0 @@ -When asked about your email use the list_emails tool. -When asked for today's schedule used the todays_schedule tool. -Please always list the email ID in your responses. \ No newline at end of file diff --git a/src/goose/toolkit/prompts/jira.jinja b/src/goose/toolkit/prompts/jira.jinja deleted file mode 100644 index 65dcad9e..00000000 --- a/src/goose/toolkit/prompts/jira.jinja +++ /dev/null @@ -1,21 +0,0 @@ -You can interact with jira issues via the `jira` command line generally. -If it fails to auth, prompt the user to run `jira init` in a separate terminal and then try again. - -Typically when someone requests you to look at a ticket, they mean to view -not just the top level comments and history, but also the comments nested within that ticket and status. - -Some usages are for looking up a JIRA backlog, or looking up a JIRA issue. -Use the tool is_jira_issue if not sure that a string that looks like a jira issue is. - -Use `jira --help` if not sure of command line options. - -If the jira command line is not installed, you can install it as follows: - -On macos: -```sh -brew tap ankitpokhrel/jira-cli -brew install jira-cli -``` - -On other operating systems or for alternative installation methods, refer to the instructions here: -https://github.com/ankitpokhrel/jira-cli diff --git a/src/goose/toolkit/prompts/memory.jinja b/src/goose/toolkit/prompts/memory.jinja deleted file mode 100644 index d5f21a24..00000000 --- a/src/goose/toolkit/prompts/memory.jinja +++ /dev/null @@ -1,69 +0,0 @@ -I have access to a memory system that helps me store and recall information across conversations. The memories are organized into categories and can be tagged for better searchability. - -I can: -1. Remember information in categories (like "development", "preferences", "personal") with optional tags -2. Search through memories by text or tags -3. List all memory categories -4. Remove entire categories of memories - -When users share important information like: -- Their name or personal details -- Project preferences -- Common tasks or workflows -- Configuration settings - -I should: -1. Identify the key piece of information -2. Ask if they'd like me to remember it for future reference -3. If they agree: - - Suggest an appropriate category (e.g., "personal" for preferences, "development" for coding practices) - - Ask if they want any specific tags for easier searching - - Ask whether to store it: - - Locally (.goose/memory) for project-specific information - - Globally (~/.config/goose/memory) for user-wide preferences -4. Use the remember tool with the chosen category, tags, and scope - -Example: -User: "For this project, we use black for code formatting" -Assistant: "I notice you mentioned a development preference. Would you like me to remember this for future conversations?" -User: "Yes please" -Assistant: "I'll store this in the 'development' category. Would you like me to add any specific tags? For example: #formatting #tools" -User: "Yes, those tags work" -Assistant: "And should I store this locally for just this project, or globally for all projects?" -User: "Locally please" -Assistant: *uses remember tool with category="development", tags="formatting tools", scope="local"* - -{% if memories.has_memories %} -Here are the existing memories I have access to: - -{% if memories.global %} -Global memories: -{% for category, category_memories in memories.global.items() %} - -Category: {{ category }} -{% for memory in category_memories %} -- {{ memory.text }}{% if memory.tags %} [tags: {{ memory.tags|join(' ') }}]{% endif %} -{% endfor %} -{% endfor %} -{% endif %} - -{% if memories.local %} -Local memories: -{% for category, category_memories in memories.local.items() %} - -Category: {{ category }} -{% for memory in category_memories %} -- {{ memory.text }}{% if memory.tags %} [tags: {{ memory.tags|join(' ') }}]{% endif %} -{% endfor %} -{% endfor %} -{% endif %} - -{% else %} -No existing memories found. -{% endif %} - -I should always: -- Ask before storing information -- Suggest relevant categories and tags -- Clarify the storage scope -- Provide feedback about what was stored \ No newline at end of file diff --git a/src/goose/toolkit/prompts/reasoner.jinja b/src/goose/toolkit/prompts/reasoner.jinja deleted file mode 100644 index 0129eb6b..00000000 --- a/src/goose/toolkit/prompts/reasoner.jinja +++ /dev/null @@ -1,5 +0,0 @@ -It is important to use deep reasoning and thinking tools when working with code, especially solving problems or new issues. -Writing code requires deep thinking and reasoning at times, which can be used to provide ideas, solutions, and to check other solutions. -Always use your generate_code tool when writing code especially on a new problem, -and use deep_reason to check solutions or when there have been errors or solutions are not clear. -Consider these tools as expert consultants that can provide advice and code that you may use. \ No newline at end of file diff --git a/src/goose/toolkit/reasoner.py b/src/goose/toolkit/reasoner.py deleted file mode 100644 index fe8f3aa2..00000000 --- a/src/goose/toolkit/reasoner.py +++ /dev/null @@ -1,77 +0,0 @@ -from exchange import Exchange, Message, Text -from exchange.content import Content -from exchange.providers import OpenAiProvider -from goose.toolkit.base import Toolkit, tool -from goose.utils.ask import ask_an_ai - - -class Reasoner(Toolkit): - """Deep thinking toolkit for reasoning through problems and solutions""" - - def message_content(self, content: Content) -> Text: - if isinstance(content, Text): - return content - else: - return Text(str(content)) - - @tool - def deep_reason(self, problem: str) -> str: - """ - Debug or reason about challenges or problems. - It will take a minute to think about it and consider solutions. - - Args: - problem (str): description of problem or errors seen. - - Returns: - response (str): A solution, which may include a suggestion or code snippet. - """ - # Create an instance of Exchange with the inlined OpenAI provider - self.notifier.status("thinking...") - provider = OpenAiProvider.from_env() - - # Create messages list - existing_messages_copy = [ - Message(role=msg.role, content=[self.message_content(content) for content in msg.content]) - for msg in self.exchange_view.processor.messages - ] - exchange = Exchange(provider=provider, model="o1-preview", messages=existing_messages_copy, system=None) - - response = ask_an_ai(input="please help reason about this: " + problem, exchange=exchange, no_history=False) - return response.content[0].text - - @tool - def generate_code(self, instructions: str) -> str: - """ - reason about and write code based on instructions given. - this will consider and reason about the instructions and come up with code to solve it. - - Args: - instructions (str): instructions of what code to write or how to modify it. - - Returns: - response (str): generated code to be tested or applied. Not it will not write directly to files so you have to take it and process it if it is suitable. - """ # noqa: E501 - # Create an instance of Exchange with the inlined OpenAI provider - provider = OpenAiProvider.from_env() - - # clone messages, converting to text for context - existing_messages_copy = [ - Message(role=msg.role, content=[self.message_content(content) for content in msg.content]) - for msg in self.exchange_view.processor.messages - ] - exchange = Exchange(provider=provider, model="o1-mini", messages=existing_messages_copy, system=None) - - self.notifier.status("generating code...") - response = ask_an_ai( - input="Please follow the instructions, " - + "and ideally return relevant code and little commentary:" - + instructions, - exchange=exchange, - no_history=False, - ) - return response.content[0].text - - def system(self) -> str: - """Retrieve instructions on how to use this reasoning and code generation tool""" - return Message.load("prompts/reasoner.jinja").text diff --git a/src/goose/toolkit/repo_context/__init__.py b/src/goose/toolkit/repo_context/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/goose/toolkit/repo_context/prompts/repo_context.jinja b/src/goose/toolkit/repo_context/prompts/repo_context.jinja deleted file mode 100644 index 5a1686af..00000000 --- a/src/goose/toolkit/repo_context/prompts/repo_context.jinja +++ /dev/null @@ -1,39 +0,0 @@ -Given a dictionary of files and directories in a project repository, please identify which files should be retained -based solely on their relevance to the core processing code of the project. Exclude configuration-related files and other -non-code files, except for necessary Dockerfiles and Markdown files. You do not need to read or open the files or -directories. Just make an educated guess. - -**Important:** Return the file and directory names exactly as they appear in the input list. Do not modify, alter, or -assume different names. Any suggested file or directory must match an entry in the input list. - -Return ONLY a dictionary of relevant files and potentially relevant directories that need further inspection. NO -MARKDOWN NOTATION. - -Example: - -Input: - -{ - 'files': [ - 'LICENSE.md', - 'ARCHITECTURE.md', - 'mkdocs.yml', - 'justfile', - 'CHANGELOG.md', - 'pyproject.toml', - 'README.md', - 'CONTRIBUTING.md', - 'poetry.lock' - ], - 'directories': ['bin', 'tests', 'docs', 'mlruns', 'scripts', 'src'] -} - -Output: - -{ - 'files': [ - 'ARCHITECTURE.md', - 'README.md', - ], - 'directories': ['tests', 'scripts', 'src'] -} diff --git a/src/goose/toolkit/repo_context/repo_context.py b/src/goose/toolkit/repo_context/repo_context.py deleted file mode 100644 index b8d0f1aa..00000000 --- a/src/goose/toolkit/repo_context/repo_context.py +++ /dev/null @@ -1,110 +0,0 @@ -import os -from functools import cache -from subprocess import CompletedProcess, run - -from exchange import Message - -from goose.notifier import Notifier -from goose.toolkit import Toolkit -from goose.toolkit.base import Requirements, tool -from goose.toolkit.repo_context.utils import get_repo_size, goose_picks_files -from goose.toolkit.summarization.utils import load_summary_file_if_exists, summarize_files_concurrent -from goose.utils.ask import clear_exchange, replace_prompt - - -class RepoContext(Toolkit): - """Provides context about the current repository""" - - def __init__(self, notifier: Notifier, requires: Requirements) -> None: - super().__init__(notifier=notifier, requires=requires) - - self.repo_project_root, self.is_git_repo, self.goose_session_root = self.determine_git_proj() - - def determine_git_proj(self) -> tuple[str, bool, str]: - """Determines the root as well as where Goose is currently running - - If the project is not part of a Github repo, the root of the project will be defined as the current working - directory - - Returns: - str: path to the root of the project (if part of a local repository) or the CWD if not - boolean: if Goose is operating within local repository or not - str: path to where the Goose session is running (the CWD) - """ - # FIXME: monorepos - cwd = os.getcwd() - command = "git rev-parse --show-toplevel" - result: CompletedProcess = run(command, shell=True, text=True, capture_output=True, check=False) - if result.returncode == 0: - project_root = result.stdout.strip() - return project_root, True, cwd - else: - self.notifier.log("Not part of a Git repository. Returning current working directory") - return cwd, False, cwd - - @property - @cache - def repo_size(self) -> float: - """Returns the size of the repo in MB (if Goose detects its running in a local repository - - This measurement can be used to guess if the local repository is a monorepo - - Returns: - float: size of project in MB - """ - # in MB - if self.is_git_repo: - return get_repo_size(self.repo_project_root) - else: - self.notifier.log("Not a git repo. Returning 0.") - return 0.0 - - @property - def is_mono_repo(self) -> bool: - """An boolean indicator of whether the local repository is part of a monorepo - - Returns: - boolean: True if above 2000 MB; False otherwise - """ - # java: 6394.367112159729 - # go: 3729.93 MB - return self.repo_size > 2000 - - @tool - def summarize_current_project(self) -> dict[str, str]: - """Summarizes the current project based on repo root (if git repo) or current project_directory (if not) - - Returns: - summary (dict[str, str]): Keys are file paths and values are the summaries - """ - - self.notifier.log("Summarizing the most relevant files in the current project. This may take a while...") - - if self.is_mono_repo: - self.notifier.log("This might be a monorepo. Goose performs better on smaller projects. Using CWD.") - # TODO: prompt user to specify a subdirectory - project_directory = self.goose_session_root - else: - project_directory = self.repo_project_root - - # before selecting files and summarizing look for summarization file - project_name = project_directory.split("/")[-1] - summary = load_summary_file_if_exists(project_name=project_name) - if summary: - self.notifier.log("Summary file for project exists already -- loading into the context") - return summary - - # clear exchange and replace the system prompt with instructions on why and how to select files to summarize - file_select_exchange = clear_exchange(self.exchange_view.accelerator, clear_tools=True) - system = Message.load("prompts/repo_context.jinja").text - file_select_exchange = replace_prompt(exchange=file_select_exchange, prompt=system) - files = goose_picks_files(root=project_directory, exchange=file_select_exchange) - - # summarize the selected files using a blank exchange with no tools - summary = summarize_files_concurrent( - exchange=clear_exchange(self.exchange_view.accelerator, clear_tools=True), - file_list=files, - project_name=project_directory.split("/")[-1], - ) - - return summary diff --git a/src/goose/toolkit/repo_context/utils.py b/src/goose/toolkit/repo_context/utils.py deleted file mode 100644 index e69cea93..00000000 --- a/src/goose/toolkit/repo_context/utils.py +++ /dev/null @@ -1,103 +0,0 @@ -import ast -import concurrent.futures -import os -from collections import deque - -from exchange import Exchange - -from goose.utils.ask import ask_an_ai - - -def get_directory_size(directory: str) -> int: - total_size = 0 - for dirpath, _, filenames in os.walk(directory): - for f in filenames: - fp = os.path.join(dirpath, f) - # Skip if it is a symbolic link - if not os.path.islink(fp): - total_size += os.path.getsize(fp) - return total_size - - -def get_repo_size(repo_path: str) -> int: - """Returns repo size in MB""" - git_dir = os.path.join(repo_path, ".git") - return get_directory_size(git_dir) / (1024**2) - - -def get_files_and_directories(root_dir: str) -> dict[str, list]: - """Gets file names and directory names. Checks that goose has correctly typed the file and directory names and that - the files actually exist (to avoid downstream file read errors). - - Args: - root_dir (str): Path to the directory to examine for files and sub-directories - - Returns: - dict: A list of files and directories in the form {'files': [], 'directories: []}. Paths - are all relative (i.e. ['src'] not ['goose/src']) - """ - files = [] - dirs = [] - - # check dir exists - try: - os.listdir(root_dir) - except FileNotFoundError: - # FIXME: fuzzy match might work here to recover directories 'lost' to goose mistyping - # hallucination: Goose mistyped the path (e.g. `metrichandler` vs `metricshandler`) - return {"files": files, "directories": dirs} - - for entry in os.listdir(root_dir): - if entry.startswith(".") or entry.startswith("~"): - continue # Skip hidden files and directories - - full_path = os.path.join(root_dir, entry) - if os.path.isdir(full_path): - dirs.append(entry) - elif os.path.isfile(full_path): - files.append(entry) - - return {"files": files, "directories": dirs} - - -def goose_picks_files(root: str, exchange: Exchange, max_workers: int = 4) -> list[str]: - """Lets goose pick files in a BFS manner""" - queue = deque([root]) - - all_files = [] - - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - while queue: - current_batch = [queue.popleft() for _ in range(min(max_workers, len(queue)))] - futures = {executor.submit(process_directory, dir, exchange): dir for dir in current_batch} - - for future in concurrent.futures.as_completed(futures): - files, next_dirs = future.result() - all_files.extend(files) - queue.extend(next_dirs) - - return all_files - - -def process_directory(current_dir: str, exchange: Exchange) -> tuple[list[str], list[str]]: - """Allows goose to pick files and subdirectories contained in a given directory (current_dir). Get the list of file - and directory names in the current folder, then ask Goose to pick which ones to keep. - - """ - files_and_dirs = get_files_and_directories(current_dir) - ai_response = ask_an_ai(str(files_and_dirs), exchange) - - # FIXME: goose response validation - try: - as_dict = ast.literal_eval(ai_response.text) - except Exception: - # can happen if goose returns anything but {result: dict} (e.g. ```json\n {results: dict} \n```) - return [], [] - if not isinstance(as_dict, dict): - # can happen if goose returns something like `{'files': ['x.py'] 'directories': ['dir1']}` (missing comma) - return [], [] - - files = [f"{current_dir}/{file}" for file in as_dict.get("files", [])] - next_dirs = [f"{current_dir}/{next_dir}" for next_dir in as_dict.get("directories", [])] - - return files, next_dirs diff --git a/src/goose/toolkit/screen.py b/src/goose/toolkit/screen.py deleted file mode 100644 index f0cc5722..00000000 --- a/src/goose/toolkit/screen.py +++ /dev/null @@ -1,44 +0,0 @@ -import subprocess -import uuid - -from rich.markdown import Markdown -from rich.panel import Panel - -from goose.toolkit.base import Toolkit, tool - - -class Screen(Toolkit): - """Provides an instructions on when and how to work with screenshots""" - - @tool - def take_screenshot(self, display: int = 1) -> str: - """ - Take a screenshot to assist the user in debugging or designing an app. They may need - help with moving a screen element, or interacting in some way where you could do with - seeing the screen. - - Args: - display (int): Display to take the screen shot in. Default is the main display (1). Must be a value greater than 1. - """ # noqa: E501 - # Generate a random tmp filename for screenshot - filename = f"/tmp/goose_screenshot_{uuid.uuid4().hex}.jpg" - screen_capture_command = ["screencapture", "-x", "-D", str(display), filename, "-f", "jpg"] - - subprocess.run(screen_capture_command, check=True, capture_output=True) - - resize_command = ["sips", "--resampleWidth", "768", filename, "-s", "format", "jpeg"] - subprocess.run(resize_command, check=True, capture_output=True) - - self.notifier.log( - Panel.fit( - Markdown(f"```bash\n{' '.join(screen_capture_command)}"), - title="screen", - ) - ) - - return f"image:{filename}" - - # Provide any system instructions for the model - # This can be generated dynamically, and is run at startup time - def system(self) -> str: - return """**When the user wants you to help debug, or work on a visual design by looking at their screen, IDE or browser, call the take_screenshot and send the output from the user.**""" # noqa: E501 diff --git a/src/goose/toolkit/summarization/__init__.py b/src/goose/toolkit/summarization/__init__.py deleted file mode 100644 index 3a4b2591..00000000 --- a/src/goose/toolkit/summarization/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .summarize_repo import SummarizeRepo # noqa -from .summarize_project import SummarizeProject # noqa -from .summarize_file import SummarizeFile # noqa diff --git a/src/goose/toolkit/summarization/summarize_file.py b/src/goose/toolkit/summarization/summarize_file.py deleted file mode 100644 index d4685eec..00000000 --- a/src/goose/toolkit/summarization/summarize_file.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Optional - -from goose.toolkit import Toolkit -from goose.toolkit.base import tool -from goose.toolkit.summarization.utils import summarize_file - - -class SummarizeFile(Toolkit): - @tool - def summarize_file(self, filepath: str, prompt: Optional[str] = None) -> str: - """ - Tool to summarize a specific file - - Args: - filepath (str): Path to the file to summarize - prompt (str): Optional prompt giving the model instructions on how to summarize the file. - Under the hood this defaults to "Please summarize this file" - - Returns: - summary (Optional[str]): Summary of the file contents - - """ - - exchange = self.exchange_view.accelerator - - _, summary = summarize_file(filepath=filepath, exchange=exchange, prompt=prompt) - - return summary diff --git a/src/goose/toolkit/summarization/summarize_project.py b/src/goose/toolkit/summarization/summarize_project.py deleted file mode 100644 index f5e22562..00000000 --- a/src/goose/toolkit/summarization/summarize_project.py +++ /dev/null @@ -1,37 +0,0 @@ -import os -from typing import Optional - -from goose.toolkit import Toolkit -from goose.toolkit.base import tool -from goose.toolkit.summarization.utils import summarize_directory - - -class SummarizeProject(Toolkit): - @tool - def get_project_summary( - self, - project_dir_path: Optional[str] = os.getcwd(), - extensions: Optional[list[str]] = None, - summary_instructions_prompt: Optional[str] = None, - ) -> dict: - """Generates or retrieves a project summary based on specified file extensions. - - Args: - project_dir_path (Optional[Path]): Path to the project directory. Defaults to the current working directory - if None - extensions (Optional[list[str]]): Specific file extensions to summarize. - summary_instructions_prompt (Optional[str]): Instructions to give to the LLM about how to summarize each file. E.g. - "Summarize the file in two sentences.". The default instruction is "Please summarize this file." - - Returns: - summary (dict): Project summary. - """ # noqa: E501 - - summary = summarize_directory( - project_dir_path, - exchange=self.exchange_view.accelerator, - extensions=extensions, - summary_instructions_prompt=summary_instructions_prompt, - ) - - return summary diff --git a/src/goose/toolkit/summarization/summarize_repo.py b/src/goose/toolkit/summarization/summarize_repo.py deleted file mode 100644 index 58765dd9..00000000 --- a/src/goose/toolkit/summarization/summarize_repo.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Optional - -from goose.toolkit import Toolkit -from goose.toolkit.base import tool -from goose.toolkit.summarization.utils import summarize_repo - - -class SummarizeRepo(Toolkit): - @tool - def summarize_repo( - self, - repo_url: str, - specified_extensions: Optional[list[str]] = None, - summary_instructions_prompt: Optional[str] = None, - ) -> dict: - """ - Retrieves a summary of a repository. Clones the repository if not already cloned and summarizes based on the - specified file extensions. If no extensions are specified, it summarizes the top `max_extensions` extensions. - - Args: - repo_url (str): The URL of the repository to summarize. - specified_extensions (Optional[list[str]]): list of file extensions to summarize, e.g., ["tf", "md"]. If - this list is empty, then all files in the repo are summarized - summary_instructions_prompt (Optional[str]): Instructions to give to the LLM about how to summarize each file. E.g. - "Summarize the file in two sentences.". The default instruction is "Please summarize this file." - - Returns: - summary (dict): A summary of the repository where keys are the file extensions and values are their - summaries. - """ # noqa: E501 - - return summarize_repo( - repo_url=repo_url, - exchange=self.exchange_view.accelerator, - extensions=specified_extensions, - summary_instructions_prompt=summary_instructions_prompt, - ) diff --git a/src/goose/toolkit/summarization/utils.py b/src/goose/toolkit/summarization/utils.py deleted file mode 100644 index 96e5d363..00000000 --- a/src/goose/toolkit/summarization/utils.py +++ /dev/null @@ -1,199 +0,0 @@ -import json -import subprocess -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -from typing import Optional - -from exchange import Exchange -from exchange.providers.utils import InitialMessageTooLargeError - -from goose.utils.ask import ask_an_ai -from goose.utils.file_utils import create_file_list - -SUMMARIES_FOLDER = ".goose/summaries" -CLONED_REPOS_FOLDER = ".goose/cloned_repos" - - -# TODO: move git stuff -def run_git_command(command: list[str]) -> subprocess.CompletedProcess[str]: - result = subprocess.run(["git"] + command, capture_output=True, text=True, check=False) - - if result.returncode != 0: - raise Exception(f"Git command failed with message: {result.stderr.strip()}") - - return result - - -def clone_repo(repo_url: str, target_directory: str) -> None: - run_git_command(["clone", repo_url, target_directory]) - - -def load_summary_file_if_exists(project_name: str) -> Optional[dict]: - """Checks if a summary file exists at '.goose/summaries/projectname-summary.json. Returns contents of the file if - it exists, otherwise returns None - - Args: - project_name (str): name of the project or repo - - Returns: - Optional[dict]: File contents, else None - """ - summary_file_path = f"{SUMMARIES_FOLDER}/{project_name}-summary.json" - if Path(summary_file_path).exists(): - with open(summary_file_path, "r") as f: - return json.load(f) - - -def summarize_file(filepath: str, exchange: Exchange, prompt: Optional[str] = None) -> tuple[str, str]: - """Summarizes a single file - - Args: - filepath (str): Path to the file to summarize. - exchange (Exchange): Exchange object to use for summarization. - prompt (Optional[str]): Defaults to "Please summarize this file." - """ - try: - with open(filepath, "r") as f: - file_text = f.read() - except Exception as e: - return filepath, f"Error reading file {filepath}: {str(e)}" - - if not file_text: - return filepath, "Empty file" - - try: - reply = ask_an_ai( - input=file_text, exchange=exchange, prompt=prompt if prompt else "Please summarize this file." - ) - except InitialMessageTooLargeError: - return filepath, "File too large" - - return filepath, reply.text - - -def summarize_repo( - repo_url: str, - exchange: Exchange, - extensions: list[str], - summary_instructions_prompt: Optional[str] = None, -) -> dict[str, str]: - """Clones (if needed) and summarizes a repo - - Args: - repo_url (str): Repository url - exchange (Exchange): Exchange for summarizing the repo. - extensions (list[str]): list of file-types to summarize. - summary_instructions_prompt (Optional[str]): Optional parameter to customize summarization results. Defaults to - "Please summarize this file" - """ - # set up the paths for the repository and the summary file - repo_name = repo_url.split("/")[-1] - repo_dir = f"{CLONED_REPOS_FOLDER}/{repo_name}" # e.g. '.goose/cloned_repos/' - - if Path(repo_dir).exists(): - # TODO: re-add ability to log - return summarize_directory( - directory=repo_dir, - exchange=exchange, - extensions=extensions, - summary_instructions_prompt=summary_instructions_prompt, - ) - - clone_repo(repo_url, target_directory=repo_dir) - - return summarize_directory( - directory=repo_dir, - exchange=exchange, - extensions=extensions, - summary_instructions_prompt=summary_instructions_prompt, - ) - - -def summarize_directory( - directory: str, exchange: Exchange, extensions: list[str], summary_instructions_prompt: Optional[str] = None -) -> dict[str, str]: - """Summarize files in a given directory based on extensions. Will also recursively find files in subdirectories and - summarize them. - - Args: - directory (str): path to the top-level directory to summarize - exchange (Exchange): Exchange to use to summarize - extensions (list[str]): list of file-type extensions to summarize (and ignore all other extensions). - summary_instructions_prompt (Optional[str]): Optional instructions to give to the exchange regarding summarization. - - Returns: - file_summaries (dict): Keys are file names and values are summaries. - - """ # noqa: E501 - - # TODO: make sure that '.goose/summaries' is - # in the root of the current not relative to current dir or in cloned repo root - project_name = directory.split("/")[-1] - summary_file = load_summary_file_if_exists(project_name) - if summary_file: - return summary_file - - summary_file_path = f"{SUMMARIES_FOLDER}/{project_name}-summary.json" - - # create the .goose/summaries folder if not already created - Path(SUMMARIES_FOLDER).mkdir(exist_ok=True, parents=True) - - # select a subset of files to summarize based on file extension - files_to_summarize = create_file_list(directory, extensions=extensions) - - file_summaries = summarize_files_concurrent( - exchange=exchange, - file_list=files_to_summarize, - project_name=project_name, - summary_instructions_prompt=summary_instructions_prompt, - ) - - summary_file_contents = {"extensions": extensions, "summaries": file_summaries} - - # Write the summaries into a json - with open(summary_file_path, "w") as f: - json.dump(summary_file_contents, f, indent=2) - - return file_summaries - - -def summarize_files_concurrent( - exchange: Exchange, file_list: list[str], project_name: str, summary_instructions_prompt: Optional[str] = None -) -> dict[str, str]: - """Takes in a list of files and summarizes them. Exchange does not keep history of the summarized files. - - Args: - exchange (Exchange): Underlying exchange - file_list (list[str]): list of paths to files to summarize - project_name (str): Used to save the summary of the files to .goose/summaries/-summary.json - summary_instructions_prompt (Optional[str]): Summary instructions for the LLM. Defaults to "Please summarize - this file." - - Returns: - file_summaries (dict[str, str]): Keys are file paths and values are the summaries returned by the Exchange - """ - summary_file = load_summary_file_if_exists(project_name) - if summary_file: - return summary_file - - file_summaries = {} - # compile the individual file summaries into a single summary dict - # TODO: add progress bar as this step can take quite some time and it's nice to see something is happening - with ThreadPoolExecutor() as executor: - future_to_file = { - executor.submit(summarize_file, file, exchange, summary_instructions_prompt): file for file in file_list - } - - for future in as_completed(future_to_file): - file_name, file_summary = future.result() - file_summaries[file_name] = file_summary - - # create summaries folder if it doesn't exist - Path(SUMMARIES_FOLDER).mkdir(exist_ok=True, parents=True) - summary_file_path = f"{SUMMARIES_FOLDER}/{project_name}-summary.json" - - # Write the summaries into a json - with open(summary_file_path, "w") as f: - json.dump(file_summaries, f, indent=2) - - return file_summaries diff --git a/src/goose/toolkit/utils.py b/src/goose/toolkit/utils.py deleted file mode 100644 index 470d1c84..00000000 --- a/src/goose/toolkit/utils.py +++ /dev/null @@ -1,83 +0,0 @@ -from pathlib import Path -from typing import Optional - -from pygments.lexers import get_lexer_for_filename -from pygments.util import ClassNotFound - -from jinja2 import Environment, FileSystemLoader - - -RULESTYLE = "bold" -RULEPREFIX = f"[{RULESTYLE}]───[/] " - - -def get_language(filename: str) -> str: - """ - Determine the programming language of a file based on its filename extension. - - Args: - filename (str): The name of the file for which to determine the programming language. - - Returns: - str: The name of the programming language if recognized, otherwise an empty string. - """ - try: - lexer = get_lexer_for_filename(filename) - return lexer.name.lower() - except ClassNotFound: - return "" - - -def render_template(template_path: Path, context: Optional[dict] = None) -> str: - """ - Renders a Jinja2 template given a Pathlib path, with no context needed. - - :param template_path: Path to the Jinja2 template file. - :param context: Optional dictionary containing the context for rendering the template. - :return: Rendered template as a string. - """ - # Ensure the path is absolute and exists - if not template_path.is_absolute(): - template_path = template_path.resolve() - - if not template_path.exists(): - raise FileNotFoundError(f"Template file {template_path} does not exist.") - - env = Environment(loader=FileSystemLoader(template_path.parent)) - template = env.get_template(template_path.name) - return template.render(context or {}) - - -def find_last_task_group_index(input_str: str) -> int: - lines = input_str.splitlines() - last_group_start_index = -1 - current_group_start_index = -1 - - for i, line in enumerate(lines): - line = line.strip() - if line.startswith("-"): - # If this is the first line of a new group, mark its start - if current_group_start_index == -1: - current_group_start_index = i - else: - # If we encounter a non-hyphenated line and had a group, update last group start - if current_group_start_index != -1: - last_group_start_index = current_group_start_index - current_group_start_index = -1 # Reset for potential future groups - - # If the input ended in a task group, update the last group index - if current_group_start_index != -1: - last_group_start_index = current_group_start_index - return last_group_start_index - - -def parse_plan(input_plan_str: str) -> dict: - last_group_start_index = find_last_task_group_index(input_plan_str) - if last_group_start_index == -1: - return {"kickoff_message": input_plan_str, "tasks": []} - - kickoff_message_list = input_plan_str.splitlines()[:last_group_start_index] - kickoff_message = "\n".join(kickoff_message_list).strip() - tasks_list = input_plan_str.splitlines()[last_group_start_index:] - tasks_list_output = [s[1:] for s in tasks_list if s.strip()] # filter leading - - return {"kickoff_message": kickoff_message, "tasks": tasks_list_output} diff --git a/src/goose/toolkit/web_browser.py b/src/goose/toolkit/web_browser.py deleted file mode 100644 index 0e4a774d..00000000 --- a/src/goose/toolkit/web_browser.py +++ /dev/null @@ -1,470 +0,0 @@ -import importlib.util -import os -import random -import shutil -import subprocess -import sys -import time -from typing import Callable - -# Windows-specific import -# if sys.platform.startswith("win"): -# import winreg - -# Check and install selenium if not installed -if importlib.util.find_spec("selenium") is None: - subprocess.check_call(["python", "-m", "pip", "install", "selenium"]) -from bs4 import BeautifulSoup -from exchange import Message -from pyshadow.main import Shadow -from selenium import webdriver -from selenium.common.exceptions import InvalidSessionIdException, NoSuchElementException, TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.common.action_chains import ActionChains -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support import expected_conditions as ec -from selenium.webdriver.support.ui import WebDriverWait - -from goose.toolkit.base import Toolkit, tool - - -class BrowserToolkit(Toolkit): - """A toolkit for interacting with web browsers using Selenium.""" - - def __init__(self, *args: object, **kwargs: dict[str, object]) -> None: - super().__init__(*args, **kwargs) - self.driver = None - self.history = [] - self.session_dir = ".goose/browsing_session" - os.makedirs(self.session_dir, exist_ok=True) - self.cached_url = "" - - def _initialize_driver(self, force_restart: bool = False, mock_driver: object = None) -> None: - """Initialize the web driver if not already initialized or if a restart is forced.""" - if self.driver is None or force_restart: - if mock_driver: - self.driver = mock_driver - return - if self.driver is not None: - try: - self.driver.quit() - self.notifier.notify("Previous browser session closed.") - except Exception as e: - self.notifier.notify(f"Error closing previous session: {str(e)}") - self.driver = None - subprocess.run(["pkill", "-f", "webdriver"]) # Attempt to close all previous browser instances - self.notifier.notify("All previous browser instances terminated.") - if self.driver is not None: - try: - self.driver.quit() - except Exception as e: - self.notifier.notify(f"Error closing driver: {str(e)}") - - browser_name = self._get_default_browser() - - try: - if "chrome" in browser_name.lower(): - options = webdriver.ChromeOptions() - self.driver = webdriver.Chrome(options=options) - elif "firefox" in browser_name.lower(): - self.driver = webdriver.Firefox() - else: - self.driver = webdriver.Firefox() - - try: - self.driver.set_window_size(835, 1024) - except Exception: - pass # Ignore window sizing errors if they occur - except Exception as e: - self.notifier.notify(f"Failed to initialize browser driver: {str(e)}") - self.notifier.notify("Falling back to Firefox.") - self.driver = webdriver.Firefox() - - def _get_default_browser(self) -> str: - return get_default_browser() - - def system(self) -> str: - return Message.load("prompts/browser.jinja").text - - def safe_execute(self, func: Callable, *args: object, **kwargs: dict[str, object]) -> object: - """Safely execute a browser action, restart the driver if needed.""" - try: - return func(*args, **kwargs) - except (TimeoutException, NoSuchElementException, InvalidSessionIdException, Exception) as e: - self.notifier.notify(f"Error during browser action: {str(e)}") - self._initialize_driver(force_restart=True) - return func(*args, **kwargs) - - @tool - def navigate_to(self, url: str) -> None: - """Navigate or browse to a specified URL in the browser. - - Args: - url (str): The URL to navigate to. - """ - self._initialize_driver() - self.notifier.notify(f"Navigating to {url}") - self.safe_execute(self.driver.get, url) - self.wait_for_page_load() - self.history.append(url) - - @tool - def take_browser_screenshot(self, filename: str) -> str: - """Take a screenshot of the current browser window to assist with navigation. - - Args: - filename (str): The file path where the screenshot will be saved. - """ - try: - path = os.path.join(self.session_dir, filename) - self.driver.save_screenshot(path) - self.notifier.notify(f"Screenshot saved in browsing session: {path}") - return f"image:{path}" - except Exception as e: - self.notifier.notify(f"Error taking screenshot: {str(e)}") - - @tool - def scroll_page(self, direction: str = "down") -> None: - """Scroll the current page up or down. - - Args: - direction (str): The direction to scroll the page. Either 'up' or 'down'. - """ - actions = ActionChains(self.driver) - if direction == "up": - actions.send_keys(Keys.PAGE_UP).perform() - elif direction == "down": - actions.send_keys(Keys.PAGE_DOWN).perform() - else: - self.notifier.notify(f"Invalid scroll direction: {direction}") - - @tool - def open_new_tab(self, url: str) -> None: - """Open a new tab and navigate to the specified URL. - - Args: - url (str): The URL to navigate to in the new tab. - """ - if not self.driver: - self.notifier.notify("Driver not initialized, using navigate_to instead.") - self.navigate_to(url) - return - - self.notifier.notify(f"Opening a new tab and navigating to {url}.") - self.driver.execute_script(f"window.open('{url}', '_blank');") - self.driver.switch_to.window(self.driver.window_handles[-1]) - self.wait_for_page_load() - - @tool - def check_current_page_url(self) -> str: - """Get the URL of the current page.""" - if not self.driver: - self.notifier.notify("Driver is not initialized.") - return "" - - current_url = self.driver.current_url - self.notifier.notify(f"Current page URL: {current_url}") - return current_url - - @tool - def switch_to_tab(self, index: int) -> None: - """Switch to the browser tab at the specified index. - - Args: - index (int): The index of the tab to switch to. - """ - try: - self.notifier.notify(f"Switching to tab at index {index}.") - self.driver.switch_to.window(self.driver.window_handles[index]) - self.wait_for_page_load() - except IndexError: - self.notifier.notify(f"Invalid tab index: {index}.") - - @tool - def close_current_tab(self) -> None: - """Close the current browser tab.""" - if not self.driver: - self.notifier.notify("Cannot close the tab as the driver is not initialized.") - return - - self.notifier.notify("Closing the current tab.") - self.driver.close() - if len(self.driver.window_handles) > 0: - self.driver.switch_to.window(self.driver.window_handles[-1]) - - def refresh_page(self) -> None: - """Refresh the current browser page.""" - self.notifier.notify("Refreshing the current page.") - self.driver.refresh() - self.wait_for_page_load() - - @tool - def get_html_content(self) -> str: - """Extract the full HTML content of the current page and cache it to a file.""" - self.notifier.notify("Extracting full HTML content of the page.") - current_url = self.driver.current_url.replace("https://", "").replace("http://", "").replace("/", "_") - - if current_url != self.cached_url: - html_content = self.driver.page_source - filename = os.path.join(self.session_dir, f"{current_url}_page.html") - with open(filename, "w", encoding="utf-8") as f: - f.write(html_content) - self.cached_html_path = filename - self.cached_url = current_url - self.notifier.notify(f"HTML cached as {filename}.") - - return self.cached_html_path - - # @tool - # def run_js(self, script: str) -> str: - # """Execute custom JavaScript on the page. - # - # Args: - # script (str): JavaScript code to execute. - # - # Returns: - # str: The result of the JavaScript execution. - # """ - # self.notifier.notify("Running JavaScript in the browser.") - # return self.driver.execute_script(script) - - @tool - def type_into_input(self, selector: str, text: str) -> None: - """Type text into an input element specified by a CSS selector for the currently open page. - - Args: - selector (str): CSS selector string to locate the input element. - text (str): The text to type into the input element. - """ - retries = 3 - for attempt in range(retries): - try: - self.notifier.notify(f"Typing '{text}' into input with selector: {selector}") - element = WebDriverWait(self.driver, 20).until(ec.element_to_be_clickable((By.CSS_SELECTOR, selector))) - element.clear() - for char in text: - element.send_keys(char) - time.sleep(random.uniform(0.1, 0.3)) - break - except TimeoutException as e: - if attempt < retries - 1: - self.notifier.notify(f"Retry {attempt + 1}/{retries} due to timeout: {str(e)}") - time.sleep(2) - else: - raise - - def wait_for_page_load(self, timeout: int = 45) -> None: - """Wait for the page to fully load by checking the document readiness state. - - Args: - timeout (int): Maximum time to wait for page load, in seconds. - """ - WebDriverWait(self.driver, timeout).until( - lambda driver: driver.execute_script("return document.readyState") == "complete" - ) - self.notifier.notify("Page fully loaded.") - - @tool - def click_element(self, selector: str) -> None: - """Click a button or link specified by a CSS selector. - - Args: - selector (str): CSS selector string to locate the element. - """ - retries = 3 - for attempt in range(retries): - try: - self.notifier.notify(f"Clicking element with selector: {selector}") - element = WebDriverWait(self.driver, 20).until(ec.element_to_be_clickable((By.CSS_SELECTOR, selector))) - element.click() - self.wait_for_page_load() - break - except TimeoutException as e: - if attempt < retries - 1: - self.notifier.notify(f"Retry {attempt + 1}/{retries} due to timeout: {str(e)}") - time.sleep(2) - else: - raise - - @tool - def click_element_by_link_text(self, link_text: str, exact_match: bool = True) -> None: - """Click on a page element using the text visible on the page. - Useful when the page has multiple links or buttons, and you want to click on a specific one. - - Args: - link_text (str): The visible text of the button or link. - exact_match (bool): Whether to match the exact link text or any partial match. - """ - self.notifier.notify(f"Clicking element with text: {link_text}") - match_type = By.LINK_TEXT if exact_match else By.PARTIAL_LINK_TEXT - element = self.driver.find_element(match_type, link_text) - element.click() - - @tool - def find_element_by_text_soup(self, text: str, filename: str) -> str: - """Find an element containing the specified text using BeautifulSoup on HTML content stored in a file. - If not found, fallback to Shadow DOM search using PyShadow. - - Args: - text (str): The text content to find within an element. - filename (str): The name of the file containing the HTML content. - - """ - # Search using BeautifulSoup as previously implemented - try: - with open(filename, "r", encoding="utf-8") as file: - soup = BeautifulSoup(file, "html.parser") - element = soup.find( - lambda tag: (tag.string and text in tag.string) - or (tag.get_text() and text in tag.get_text()) - or (tag.has_attr("title") and text in tag["title"]) - or (tag.has_attr("alt") and text in tag["alt"]) - or (tag.has_attr("aria-label") and text in tag["aria-label"]) - ) - - if element: - self.notifier.notify(f"Element found with text: {text}") - return str(element) - except FileNotFoundError: - self.notifier.notify(f"File not found: {filename}") - return None - - # Fallback: search using PyShadow - try: - shadow = Shadow(self.driver) - shadow_element = shadow.find_element_by_xpath(f"//*[contains(text(), '{text}')]") - if shadow_element: - self.notifier.notify(f"Element found in shadow DOM with text: {text}") - return shadow_element.get_attribute("outerHTML") - except Exception as e: - self.notifier.notify(f"Error searching in shadow DOM: {str(e)}") - - self.notifier.notify(f"Element not found with text: {text} in either DOMs") - return None - - @tool - def find_elements_of_type(self, tag_type: str, filename: str) -> list[str]: - """Find all elements of a specific tag type using BeautifulSoup on HTML content stored in a file. - - Args: - tag_type (str): The HTML tag type to search for. - filename (str): The name of the file containing the HTML content. - """ - elements_as_strings = [] - try: - with open(filename, "r", encoding="utf-8") as file: - soup = BeautifulSoup(file, "html.parser") - elements = soup.find_all(tag_type) - elements_as_strings = [str(element) for element in elements] - self.notifier.notify(f"Found {len(elements_as_strings)} elements of type: {tag_type}") - except FileNotFoundError: - self.notifier.notify(f"File not found: {filename}") - return elements_as_strings - - def __del__(self) -> None: - # Remove the entire session directory - if os.path.exists(self.session_dir): - try: - shutil.rmtree(self.session_dir) - self.notifier.notify(f"Removed browsing session directory: {self.session_dir}") - except OSError as e: - self.notifier.notify(f"Error removing session directory: {str(e)}") - - if self.driver: - self.driver.quit() - - -# def get_default_browser_windows() -> str: -# try: -# with winreg.OpenKey( -# winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice" -# ) as key: -# prog_id, _ = winreg.QueryValueEx(key, "ProgId") -# -# with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, f"{prog_id}\\shell\\open\\command") as cmd_key: -# command, _ = winreg.QueryValueEx(cmd_key, None) -# -# if command.startswith('"'): -# executable = command.split('"')[1] -# else: -# executable = command.split(" ")[0] -# -# return os.path.basename(executable) -# -# except Exception as e: -# print(f"Error retrieving default browser on Windows: {e}") -# return None - - -def get_default_browser_macos() -> str: - try: - import os - import plistlib - - plist_path = os.path.expanduser( - "~/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist" - ) - - if not os.path.exists(plist_path): - print(f"Launch services plist not found at: {plist_path}") - return None - - with open(plist_path, "rb") as fp: - plist = plistlib.load(fp) - handlers = plist.get("LSHandlers", []) - - for handler in handlers: - scheme = handler.get("LSHandlerURLScheme") - if scheme and scheme.lower() == "http": - return handler.get("LSHandlerRoleAll") - - return None - except Exception as e: - print(f"Error retrieving default browser on macOS: {e}") - return None - - -# def get_default_browser_linux() -> str: -# try: -# result = subprocess.run( -# ["xdg-settings", "get", "default-web-browser"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True -# ) -# -# if result.returncode != 0: -# print(f"Error: {result.stderr.strip()}") -# return None -# -# desktop_file = result.stdout.strip() -# desktop_paths = [ -# os.path.expanduser("~/.local/share/applications/"), -# "/usr/share/applications/", -# "/usr/local/share/applications/", -# ] -# -# for path in desktop_paths: -# desktop_file_path = os.path.join(path, desktop_file) -# if os.path.exists(desktop_file_path): -# with open(desktop_file_path, "r") as f: -# for line in f: -# if line.startswith("Name="): -# name = line.split("=", 1)[1].strip() -# return name -# return desktop_file.replace(".desktop", "") -# -# except Exception as e: -# print(f"Error retrieving default browser on Linux: {e}") -# return None - - -def get_default_browser() -> str: - if sys.platform.startswith("darwin"): - return get_default_browser_macos() - # other platforms are not enabled yet. - # elif sys.platform.startswith("win"): - # return get_default_browser_windows() - # elif sys.platform.startswith("linux"): - # return get_default_browser_linux() - else: - print(f"Unsupported platform {sys.platform}") - return None - return None diff --git a/src/goose/tools/gmail_client.py b/src/goose/tools/gmail_client.py deleted file mode 100644 index 50b98f69..00000000 --- a/src/goose/tools/gmail_client.py +++ /dev/null @@ -1,129 +0,0 @@ -import base64 -from datetime import datetime - -from googleapiclient.discovery import build - - -class GmailClient: - def __init__(self, credentials: dict) -> None: - self.service = build("gmail", "v1", credentials=credentials) - - def list_emails(self, max_results: int = 10) -> str: - """List the emails in the user's Gmail inbox""" - try: - results = self.service.users().messages().list(userId="me", maxResults=max_results).execute() - messages = results.get("messages", []) - - if not messages: - return "No messages found." - else: - output = "Recent emails:\n" - for message in messages: - msg = self.service.users().messages().get(userId="me", id=message["id"]).execute() - subject = next( - (header["value"] for header in msg["payload"]["headers"] if header["name"] == "Subject"), - "No subject", - ) - sender = next( - (header["value"] for header in msg["payload"]["headers"] if header["name"] == "From"), - "Unknown sender", - ) - output += f"ID: {message['id']}\nFrom: {sender}\nSubject: {subject}\n\n" - return output - except Exception as e: - return f"Error listing emails: {str(e)}" - - def get_email_content(self, email_id: str) -> str: - """Get the contents of an email by its ID""" - try: - message = self.service.users().messages().get(userId="me", id=email_id, format="full").execute() - - headers = message["payload"]["headers"] - subject = next((header["value"] for header in headers if header["name"] == "Subject"), "No subject") - sender = next((header["value"] for header in headers if header["name"] == "From"), "Unknown sender") - - if "parts" in message["payload"]: - parts = message["payload"]["parts"] - body = next((part["body"]["data"] for part in parts if part["mimeType"] == "text/plain"), None) - else: - body = message["payload"]["body"]["data"] - - if body: - decoded_body = base64.urlsafe_b64decode(body.encode("ASCII")).decode("utf-8") - else: - decoded_body = "No plain text content found in the email." - - return f"From: {sender}\nSubject: {subject}\n\nBody:\n{decoded_body}" - except Exception as e: - return f"Error retrieving email: {str(e)}" - - def _format_email_date(self, date_str: str) -> str: - try: - date_obj = datetime.fromtimestamp(int(date_str) / 1000.0) - return date_obj.strftime("%Y-%m-%d %H:%M:%S") - except Exception: - return date_str - - def _get_email_content(self, msg_id: str) -> dict: - try: - message = self.service.users().messages().get(userId="me", id=msg_id, format="full").execute() - - headers = message["payload"]["headers"] - subject = next((h["value"] for h in headers if h["name"].lower() == "subject"), "No Subject") - from_header = next((h["value"] for h in headers if h["name"].lower() == "from"), "Unknown Sender") - date = self._format_email_date(message["internalDate"]) - - # Get email body - if "parts" in message["payload"]: - parts = message["payload"]["parts"] - body = "" - for part in parts: - if part["mimeType"] == "text/plain": - if "data" in part["body"]: - body += base64.urlsafe_b64decode(part["body"]["data"].encode("ASCII")).decode("utf-8") - else: - if "data" in message["payload"]["body"]: - # NOTE: Trunace the body to 100 characters. - # TODO: Add ability to look up specific emails. - body = base64.urlsafe_b64decode(message["payload"]["body"]["data"].encode("ASCII")).decode("utf-8")[ - 0:100 - ] - else: - body = "No content" - - return {"subject": subject, "from": from_header, "date": date, "body": body} - except Exception as e: - return {"error": f"Error fetching email content: {str(e)}"} - - # def list_emails(self, max_results: int = 10, output_format: str = "text") -> str: - # try: - # results = self.service.users().messages().list(userId="me", maxResults=max_results).execute() - # messages = results.get("messages", []) - - # if not messages: - # return "No emails found." - - # emails = [] - # for message in messages: - # email_content = self._get_email_content(message["id"]) - # emails.append(email_content) - - # if output_format == "json": - # return json.dumps(emails, indent=2) - - # # Format as text - # text_output = [] - # for email in emails: - # text_output.append(f"\nSubject: {email['subject']}") - # text_output.append(f"From: {email['from']}") - # text_output.append(f"Date: {email['date']}") - # text_output.append("\nBody:") - # text_output.append(email["body"]) - # text_output.append("\n" + "=" * 50) - - # return "\n".join(text_output) - - # except HttpError as error: - # raise ValueError(f"Error accessing Gmail: {str(error)}") - # except HttpError as error: - # raise ValueError(f"Error accessing Gmail: {str(error)}") diff --git a/src/goose/tools/google_calendar_client.py b/src/goose/tools/google_calendar_client.py deleted file mode 100644 index 75831f20..00000000 --- a/src/goose/tools/google_calendar_client.py +++ /dev/null @@ -1,50 +0,0 @@ -from datetime import datetime, timedelta -from typing import Any, Dict, List -from zoneinfo import ZoneInfo - -from googleapiclient.discovery import build -from googleapiclient.errors import HttpError - - -class GoogleCalendarClient: - def __init__(self, credentials: dict) -> None: - self.creds = credentials - self.service = build("calendar", "v3", credentials=credentials) - - def list_calendars(self) -> List[Dict[str, Any]]: - try: - calendars_result = self.service.calendarList().list().execute() - calendars = calendars_result.get("items", []) - return calendars - except HttpError as error: - print(f"An error occurred: {error}") - return [] - - def list_events_for_today(self) -> List[Dict[str, Any]]: - try: - # Get the start and end of the current day in UTC - now = datetime.now(ZoneInfo("UTC")) - start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) - end_of_day = start_of_day + timedelta(days=1) - - # Convert to RFC3339 format - time_min = start_of_day.isoformat() - time_max = end_of_day.isoformat() - - # Call the Calendar API - events_result = ( - self.service.events() - .list(calendarId="primary", timeMin=time_min, timeMax=time_max, singleEvents=True, orderBy="startTime") - .execute() - ) - events = events_result.get("items", []) - - if not events: - print("No events found for today.") - return [] - - return events - - except HttpError as error: - print(f"An error occurred: {error}") - return [] diff --git a/src/goose/tools/google_oauth_handler.py b/src/goose/tools/google_oauth_handler.py deleted file mode 100644 index e2cb982d..00000000 --- a/src/goose/tools/google_oauth_handler.py +++ /dev/null @@ -1,146 +0,0 @@ -import json -import os -import urllib.parse -import webbrowser -from http.server import BaseHTTPRequestHandler, HTTPServer -from threading import Thread -from typing import Any, Dict, List, Optional - -import google_auth_oauthlib.flow -from google.oauth2.credentials import Credentials - -REDIRECT_PORT = 8000 - - -class OAuthConfig: - def __init__(self, client_secrets_file: str, token_file: str, scopes: List[str]) -> None: - self.client_secrets_file: str = client_secrets_file - self.token_file: str = token_file - self.scopes: List[str] = scopes - self.auth_success_message: str = """ - - -

Authentication Successful!

-

You can now close this window and return to the terminal.

- - - """ - - -class OAuthCallbackHandler(BaseHTTPRequestHandler): - def __init__(self, *args: Any, state: Optional[str] = None, **kwargs: Any) -> None: # noqa: ANN401 - self.state: Optional[str] = state - self.credentials: Optional[Credentials] = None - super().__init__(*args, **kwargs) - - def do_GET(self) -> None: # noqa: N802 - query_components = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) - - received_state = query_components.get("state", [""])[0] - if received_state != self.state: - self.send_error(400, "State mismatch. Possible CSRF attack.") - return - - code = query_components.get("code", [""])[0] - if not code: - self.send_error(400, "No authorization code received.") - return - - try: - flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file( - self.server.oauth_config.client_secrets_file, - scopes=self.server.oauth_config.scopes, - state=received_state, - ) - - flow.redirect_uri = f"http://localhost:{self.server.server_port}/auth/google/callback/" - flow.fetch_token(code=code) - - credentials_dict = credentials_to_dict(flow.credentials) - with open(self.server.oauth_config.token_file, "w") as token_file: - json.dump(credentials_dict, token_file) - - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write(self.server.oauth_config.auth_success_message.encode()) - - self.server.credentials = flow.credentials - - except Exception as e: - self.send_error(500, f"Error exchanging authorization code: {str(e)}") - - def log_message(self, format: str, *args: Any) -> None: # noqa: ANN401 - pass - - -def credentials_to_dict(credentials: Credentials) -> Dict[str, Any]: - return { - "token": credentials.token, - "refresh_token": credentials.refresh_token, - "token_uri": credentials.token_uri, - "client_id": credentials.client_id, - "client_secret": credentials.client_secret, - "scopes": credentials.scopes, - } - - -class GoogleOAuthHandler: - def __init__(self, client_secrets_file: str, token_file: str, scopes: List[str]) -> None: - self.oauth_config: OAuthConfig = OAuthConfig(client_secrets_file, token_file, scopes) - - def get_credentials(self) -> Credentials: - if os.path.exists(self.oauth_config.token_file): - with open(self.oauth_config.token_file, "r") as token_file: - creds_dict = json.load(token_file) - return Credentials( - token=creds_dict["token"], - refresh_token=creds_dict["refresh_token"], - token_uri=creds_dict["token_uri"], - client_id=creds_dict["client_id"], - client_secret=creds_dict["client_secret"], - scopes=creds_dict["scopes"], - ) - - return self._authenticate_user() - - def _save_token(self, credentials_dict: Dict[str, Any]) -> None: - os.makedirs(os.path.dirname(self.oauth_config.token_file), exist_ok=True) - with open(self.oauth_config.token_file, "w") as token_file: - json.dump(credentials_dict, token_file) - - def _authenticate_user(self) -> Credentials: - port = REDIRECT_PORT - redirect_uri = f"http://localhost:{port}/auth/google/callback/" - - flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file( - self.oauth_config.client_secrets_file, scopes=self.oauth_config.scopes - ) - flow.redirect_uri = redirect_uri - - auth_url, state = flow.authorization_url(access_type="offline", include_granted_scopes="true", prompt="consent") - - server_address = ("", port) - httpd = HTTPServer(server_address, lambda *args, **kwargs: OAuthCallbackHandler(*args, state=state, **kwargs)) - httpd.oauth_config = self.oauth_config - httpd.credentials = None - - server_thread = Thread(target=httpd.serve_forever) - server_thread.daemon = True - server_thread.start() - - print(f"Listening on port {port}") - print("Opening browser for authentication...") - webbrowser.open(auth_url) - - print("Waiting for authentication...") - while httpd.credentials is None: - pass - - httpd.shutdown() - server_thread.join() - - credentials = httpd.credentials - self._save_token(credentials_to_dict(credentials)) - - return credentials diff --git a/src/goose/utils/__init__.py b/src/goose/utils/__init__.py deleted file mode 100644 index d9535b18..00000000 --- a/src/goose/utils/__init__.py +++ /dev/null @@ -1,70 +0,0 @@ -import random -import string -from importlib.metadata import entry_points -from typing import TypeVar, Callable - -T = TypeVar("T") - - -def load_plugins(group: str) -> dict: - """ - Load plugins based on a specified entry point group. - - This function iterates through all entry points registered under a specified group - - Args: - group (str): The entry point group to load plugins from. This should match the group specified - in the package setup where plugins are defined. - - Returns: - dict: A dictionary where each key is the entry point name, and the value is the loaded plugin object. - - Raises: - Exception: Propagates exceptions raised by entry point loading, which might occur if a plugin - is not found or if there are issues with the plugin's code. - """ - plugins = {} - # Access all entry points for the specified group and load each. - for entrypoint in entry_points(group=group): - plugin = entrypoint.load() # Load the plugin. - plugins[entrypoint.name] = plugin # Store the loaded plugin in the dictionary. - return plugins - - -def ensure(cls: type[T]) -> Callable[[any], T]: - """Convert dictionary to a class instance""" - - def converter(val: any) -> T: # noqa: ANN401 - if isinstance(val, cls): - return val - elif isinstance(val, dict): - return cls(**val) - elif isinstance(val, list): - return cls(*val) - else: - return cls(val) - - return converter - - -def ensure_list(cls: type[T]) -> Callable[[list[dict[str, any]]], type[T]]: - """Convert a list of dictionaries to class instances""" - - def converter(val: list[dict[str, any]]) -> list[T]: - output = [] - for entry in val: - output.append(ensure(cls)(entry)) - return output - - return converter - - -def droid() -> str: - return "".join( - [ - random.choice(string.ascii_lowercase), - random.choice(string.digits), - random.choice(string.ascii_lowercase), - random.choice(string.digits), - ] - ) diff --git a/src/goose/utils/_cost_calculator.py b/src/goose/utils/_cost_calculator.py deleted file mode 100644 index 1e9ccb04..00000000 --- a/src/goose/utils/_cost_calculator.py +++ /dev/null @@ -1,62 +0,0 @@ -from datetime import datetime -from typing import Optional - -from exchange.providers.base import Usage - -from goose.utils.time_utils import formatted_time - -PRICES = { - "gpt-4o": (2.50, 10.00), - "gpt-4o-2024-08-06": (2.50, 10.00), - "gpt-4o-2024-05-13": (5.00, 15.00), - "gpt-4o-mini": (0.150, 0.600), - "gpt-4o-mini-2024-07-18": (0.150, 0.600), - "o1-preview": (15.00, 60.00), - "o1-preview-2024-09-12": (15.00, 60.00), - "o1-mini": (3.00, 12.00), - "o1-mini-2024-09-12": (3.00, 12.00), - "claude-3-5-sonnet-latest": (3.00, 15.00), # Claude 3.5 Sonnet model - "claude-3-5-sonnet-2": (3.00, 15.00), - "claude-3-5-sonnet-20241022": (3.00, 15.00), - "anthropic.claude-3-5-sonnet-20241022-v2:0": (3.00, 15.00), - "claude-3-5-sonnet-v2@20241022": (3.00, 15.00), - "claude-3-5-sonnet-20240620": (3.00, 15.00), - "anthropic.claude-3-5-sonnet-20240620-v1:0": (3.00, 15.00), - "claude-3-opus-latest": (15.00, 75.00), # Claude Opus 3 model - "claude-3-opus-20240229": (15.00, 75.00), - "anthropic.claude-3-opus-20240229-v1:0": (15.00, 75.00), - "claude-3-opus@20240229": (15.00, 75.00), - "claude-3-sonnet-20240229": (3.00, 15.00), # Claude Sonnet 3 model - "anthropic.claude-3-sonnet-20240229-v1:0": (3.00, 15.00), - "claude-3-sonnet@20240229": (3.00, 15.00), - "claude-3-haiku-20240307": (0.25, 1.25), # Claude Haiku 3 model - "anthropic.claude-3-haiku-20240307-v1:0": (0.25, 1.25), - "claude-3-haiku@20240307": (0.25, 1.25), -} - - -def _calculate_cost(model: str, token_usage: Usage) -> Optional[float]: - model_name = model.lower() - if model_name in PRICES: - input_token_price, output_token_price = PRICES[model_name] - return (input_token_price * token_usage.input_tokens + output_token_price * token_usage.output_tokens) / 1000000 - return None - - -def get_total_cost_message( - token_usages: dict[str, Usage], session_name: str, start_time: datetime, end_time: datetime -) -> str: - total_cost = 0 - message = "" - session_name_prefix = f"Session name: {session_name}" - for model, token_usage in token_usages.items(): - cost = _calculate_cost(model, token_usage) - if cost is not None: - message += f"{session_name_prefix} | Cost for model {model} {str(token_usage)}: ${cost:.2f}\n" - total_cost += cost - else: - message += f"{session_name_prefix} | Cost for model {model} {str(token_usage)}: Not available\n" - - datetime_range = f"{formatted_time(start_time)} - {formatted_time(end_time)}" - summary = f"{datetime_range} | {session_name_prefix} | Total cost: ${total_cost:.2f}" - return message + summary diff --git a/src/goose/utils/_create_exchange.py b/src/goose/utils/_create_exchange.py deleted file mode 100644 index de3170eb..00000000 --- a/src/goose/utils/_create_exchange.py +++ /dev/null @@ -1,52 +0,0 @@ -import os -import sys -from typing import Optional -import keyring - -from prompt_toolkit import prompt -from prompt_toolkit.shortcuts import confirm -from rich import print -from rich.panel import Panel - -from goose.build import build_exchange -from goose.cli.config import PROFILES_CONFIG_PATH -from goose.cli.session_notifier import SessionNotifier -from goose.profile import Profile -from exchange import Exchange -from exchange.invalid_choice_error import InvalidChoiceError -from exchange.providers.base import MissingProviderEnvVariableError - - -def create_exchange(profile: Profile, notifier: SessionNotifier) -> Exchange: - try: - return build_exchange(profile, notifier=notifier) - except InvalidChoiceError as e: - error_message = ( - f"[bold red]{e.message}[/bold red].\nPlease check your configuration file at {PROFILES_CONFIG_PATH}.\n" - + "Configuration doc: https://block.github.io/goose/configuration.html" - ) - print(error_message) - sys.exit(1) - except MissingProviderEnvVariableError as e: - api_key = _get_api_key_from_keychain(e.env_variable, e.provider) - if api_key is None or api_key == "": - error_message = f"{e.message}. Please set the required environment variable to continue." - print(Panel(error_message, style="red")) - sys.exit(1) - else: - os.environ[e.env_variable] = api_key - return build_exchange(profile=profile, notifier=notifier) - - -def _get_api_key_from_keychain(env_variable: str, provider: str) -> Optional[str]: - api_key = keyring.get_password("goose", env_variable) - if api_key is not None: - print(f"Using {env_variable} value for {provider} from your keychain") - else: - api_key = prompt(f"Enter {env_variable} value for {provider}:".strip()) - if api_key is not None and len(api_key) > 0: - save_to_keyring = confirm(f"Would you like to save the {env_variable} value to your keychain?") - if save_to_keyring: - keyring.set_password("goose", env_variable, api_key) - print(f"Saved {env_variable} to your key_chain. service_name: goose, user_name: {env_variable}") - return api_key diff --git a/src/goose/utils/ask.py b/src/goose/utils/ask.py deleted file mode 100644 index c0fee1bc..00000000 --- a/src/goose/utils/ask.py +++ /dev/null @@ -1,91 +0,0 @@ -from exchange import Exchange, Message, CheckpointData - - -def ask_an_ai( - input: str, - exchange: Exchange, - prompt: str = "", - no_history: bool = True, - with_tools: bool = True, -) -> Message: - """Sends a separate message to an LLM using a separate Exchange than the one underlying the Goose session. - - Can be used to summarize a file, or submit any other request that you'd like to an AI. The Exchange can have a - history/prior context, or be wiped clean (by setting no_history to True). - - Parameters: - input (str): The user's input string to be processed by the AI. Must be a non-empty string. Example: text from - a file. - exchange (Exchange): An object representing the AI exchange system which manages the state and flow of the - conversation. - prompt (str, optional): An optional new prompt to replace the current one in the exchange system. Defaults to - None. Example: "Please summarize this file." - no_history (bool, optional): A flag to determine if the conversation history should be cleared before - processing the new input. True clears the context, False retains it. Defaults to True. - - Returns: - reply (str): The AI's reply as a string. - - Raises: - TypeError: If the `input` is not a non-empty string. - Exception: If there is an issue within the exchange system, including errors from the provider or model. - - Example: - # Create an instance of an Exchange system - exchange_system = Exchange(provider=OpenAIProvider.from_env(), model="gpt-4") - - # Simulate asking the AI a question - response = ask_an_ai("What is the weather today?", exchange_system) - - print(response) # Outputs the AI's response to the question. - """ - if no_history: - exchange = clear_exchange(exchange) - - if not with_tools: - exchange = exchange.replace(tools=()) - - if prompt: - exchange = replace_prompt(exchange, prompt) - - if not input: - raise TypeError("`input` must be a string of finite length") - - msg = Message.user(input) - exchange.add(msg) - reply = exchange.reply() - - return reply - - -def clear_exchange(exchange: Exchange, clear_tools: bool = False) -> Exchange: - """Clears the exchange object - - Args: - exchange (Exchange): Exchange object to be overwritten. Messages and checkpoints are replaced with empty lists. - clear_tools (bool): Boolean to indicate whether tools should be dropped from the exchange. - - Returns: - new_exchange (Exchange) - - """ - if clear_tools: - new_exchange = exchange.replace(messages=[], checkpoint_data=CheckpointData(), tools=()) - else: - new_exchange = exchange.replace(messages=[], checkpoint_data=CheckpointData()) - return new_exchange - - -def replace_prompt(exchange: Exchange, prompt: str) -> Exchange: - """Replaces the system prompt - - Args: - exchange (Exchange): Exchange object to be overwritten. Messages and checkpoints are replaced with empty lists. - prompt (str): The system prompt. - - Returns: - new_exchange (Exchange) - """ - - new_exchange = exchange.replace(system=prompt) - return new_exchange diff --git a/src/goose/utils/autocomplete.py b/src/goose/utils/autocomplete.py deleted file mode 100644 index 6feb0807..00000000 --- a/src/goose/utils/autocomplete.py +++ /dev/null @@ -1,100 +0,0 @@ -import sys -from pathlib import Path - -from rich import print - -SUPPORTED_SHELLS = ["bash", "zsh", "fish"] - - -def is_autocomplete_installed(file: Path) -> bool: - if not file.exists(): - print(f"[yellow]{file} does not exist, creating file") - with open(file, "w") as f: - f.write("") - - # https://click.palletsprojects.com/en/8.1.x/shell-completion/#enabling-completion - if "_GOOSE_COMPLETE" in open(file).read(): - print(f"auto-completion already installed in {file}") - return True - return False - - -def setup_bash(install: bool) -> None: - bashrc = Path("~/.bashrc").expanduser() - if install: - if is_autocomplete_installed(bashrc): - return - f = open(bashrc, "a") - else: - f = sys.stdout - print(f"# add the following to your bash config, typically {bashrc}") - - with f: - f.write('eval "$(_GOOSE_COMPLETE=bash_source goose)"\n') - - if install: - print(f"installed auto-completion to {bashrc}") - print(f"run `source {bashrc}` to enable auto-completion") - - -def setup_fish(install: bool) -> None: - completion_dir = Path("~/.config/fish/completions").expanduser() - if not completion_dir.exists(): - completion_dir.mkdir(parents=True, exist_ok=True) - - completion_file = completion_dir / "goose.fish" - if install: - if is_autocomplete_installed(completion_file): - return - f = open(completion_file, "a") - else: - f = sys.stdout - print(f"# add the following to your fish config, typically {completion_file}") - - with f: - f.write("_GOOSE_COMPLETE=fish_source goose | source\n") - - if install: - print(f"installed auto-completion to {completion_file}") - - -def setup_zsh(install: bool) -> None: - zshrc = Path("~/.zshrc").expanduser() - if install: - if is_autocomplete_installed(zshrc): - return - f = open(zshrc, "a") - else: - f = sys.stdout - print(f"# add the following to your zsh config, typically {zshrc}") - - with f: - f.write("autoload -U +X compinit && compinit\n") - f.write("autoload -U +X bashcompinit && bashcompinit\n") - f.write('eval "$(_GOOSE_COMPLETE=zsh_source goose)"\n') - - if install: - print(f"installed auto-completion to {zshrc}") - print(f"run `source {zshrc}` to enable auto-completion") - - -def setup_autocomplete(shell: str, install: bool) -> None: - """Installs shell completions for goose - - Args: - shell (str): shell to install completions for - install (bool): whether to install or generate completions - """ - - match shell: - case "bash": - setup_bash(install=install) - - case "zsh": - setup_zsh(install=install) - - case "fish": - setup_fish(install=install) - - case _: - print(f"Shell {shell} not supported") diff --git a/src/goose/utils/command_checker.py b/src/goose/utils/command_checker.py deleted file mode 100644 index b495c4b3..00000000 --- a/src/goose/utils/command_checker.py +++ /dev/null @@ -1,49 +0,0 @@ -import re -from typing import List - -_dangerous_patterns = [ - # Commands that are generally unsafe - r"\brm\b", # rm command - r"\bgit\s+push\b", # git push command - r"\bsudo\b", # sudo command - r"\bmv\b", # mv command - r"\bchmod\b", # chmod command - r"\bchown\b", # chown command - r"\bmkfs\b", # mkfs command - r"\bsystemctl\b", # systemctl command - r"\breboot\b", # reboot command - r"\bshutdown\b", # shutdown command - # Commands that kill processes - r"\b(kill|pkill|killall|xkill|skill)\b", - r"\bfuser\b\s*-[kK]", # fuser -k command - # Target files that are unsafe - r"\b~\/\.|\/\.\w+", # commands that point to files or dirs in home that start with a dot (dotfiles) -] -_compiled_patterns = [re.compile(pattern) for pattern in _dangerous_patterns] - - -def is_dangerous_command(command: str) -> bool: - """ - Check if the command matches any dangerous patterns. - - Dangerous patterns in this function are defined as commands that may present risk to system stability. - - Args: - command (str): The shell command to check. - - Returns: - bool: True if the command is dangerous, False otherwise. - """ - return any(pattern.search(command) for pattern in _compiled_patterns) - - -def add_dangerous_command_patterns(patterns: List[str]) -> None: - """ - Add additional dangerous patterns to the command checker. Intended to be - called in plugins that add additional high-specificity dangerous commands. - - Args: - patterns (List[str]): The regex patterns to add to the dangerous patterns list. - """ - _dangerous_patterns.extend(patterns) - _compiled_patterns.extend([re.compile(pattern) for pattern in patterns]) diff --git a/src/goose/utils/file_utils.py b/src/goose/utils/file_utils.py deleted file mode 100644 index 1531ad65..00000000 --- a/src/goose/utils/file_utils.py +++ /dev/null @@ -1,103 +0,0 @@ -import glob -import os -from collections import Counter -from pathlib import Path -from typing import Optional - - -def create_extensions_list(project_root: str, max_n: int) -> list: - """Get the top N file extensions in the current project - Args: - project_root (str): Root of the project to analyze - max_n (int): The number of file extensions to return - Returns: - extensions (list[str]): A list of the top N file extensions - """ - if max_n == 0: - raise (ValueError("Number of file extensions must be greater than 0")) - - files = create_file_list(project_root, []) - - counter = Counter() - - for file in files: - file_path = Path(file) - if file_path.suffix: # omit '' - counter[file_path.suffix] += 1 - - top_n = counter.most_common(max_n) - extensions = [ext for ext, _ in top_n] - - return extensions - - -def create_language_weighting(files_in_directory: list[str]) -> dict[str, float]: - """Calculate language weighting by file size to match GitHub's methodology. - - Args: - files_in_directory (list[str]): Paths to files in the project directory - - Returns: - dict[str, float]: A dictionary with languages as keys and their percentage of the total codebase as values - """ - - # Initialize counters for sizes - size_by_language = Counter() - - # Calculate size for files by language - for file_path in files_in_directory: - path = Path(file_path) - if path.suffix: - size_by_language[path.suffix] += os.path.getsize(file_path) - - # Calculate total size and language percentages - total_size = sum(size_by_language.values()) - language_percentages = { - lang: (size / total_size * 100) if total_size else 0 for lang, size in size_by_language.items() - } - - return dict(sorted(language_percentages.items(), key=lambda item: item[1], reverse=True)) - - -def list_files_with_extension(dir_path: str, extension: Optional[str] = "") -> list[str]: - """List all files in a directory with a given extension. Set extension to '' to return all files. - - Args: - dir_path (str): The path to the directory - extension (Optional[str]): extension to lookup. Defaults to '' which will return all files. - - Returns: - files (list[str]): list of file paths - """ - # add a leading '.' to extension if needed - if extension and not extension.startswith("."): - extension = f".{extension}" - - files = glob.glob(f"{dir_path}/**/*{extension}", recursive=True) - return files - - -def create_file_list(dir_path: str, extensions: list[str]) -> list[str]: - """Creates a list of files with certain extensions - - Args: - dir_path (str): Directory to list files of. Will include files recursively in sub-directories. - extensions (list[str]): list of file extensions to select for. If empty list, return all files - - Returns: - final_file_list (list[str]): list of file paths with specified extensions. - """ - # if extensions is empty list, return all files - if not extensions: - return glob.glob(f"{dir_path}/**/*", recursive=True) - - # prune out files that do not end with any of the extensions in extensions - final_file_list = [] - for ext in extensions: - if ext and not ext.startswith("."): - ext = f".{ext}" - - files = glob.glob(f"{dir_path}/**/*{ext}", recursive=True) - final_file_list += files - - return final_file_list diff --git a/src/goose/utils/goosehints.py b/src/goose/utils/goosehints.py deleted file mode 100644 index a2acce76..00000000 --- a/src/goose/utils/goosehints.py +++ /dev/null @@ -1,21 +0,0 @@ -from pathlib import Path - -from goose.toolkit.utils import render_template - - -def fetch_goosehints() -> str: - hints = [] - dirs = [Path.cwd()] + list(Path.cwd().parents) - # reverse to go from parent to child - dirs.reverse() - - for dir in dirs: - hints_path = dir / ".goosehints" - if hints_path.is_file(): - hints.append(render_template(hints_path)) - - home_hints_path = Path.home() / ".config/goose/.goosehints" - if home_hints_path.is_file(): - hints.append(render_template(home_hints_path)) - - return "\n\n".join(hints) diff --git a/src/goose/utils/session_file.py b/src/goose/utils/session_file.py deleted file mode 100644 index f8b5e951..00000000 --- a/src/goose/utils/session_file.py +++ /dev/null @@ -1,55 +0,0 @@ -import json -from pathlib import Path -from typing import Iterator - -from exchange import Message - -from goose.cli.config import SESSION_FILE_SUFFIX - - -def is_existing_session(path: Path) -> bool: - return path.is_file() and path.stat().st_size > 0 - - -def is_empty_session(path: Path) -> bool: - return path.is_file() and path.stat().st_size == 0 - - -def read_or_create_file(file_path: Path) -> list[Message]: - if file_path.exists(): - return read_from_file(file_path) - with open(file_path, "w"): - pass - return [] - - -def read_from_file(file_path: Path) -> list[Message]: - try: - with open(file_path, "r") as f: - messages = [json.loads(m) for m in list(f) if m.strip()] - except json.JSONDecodeError as e: - raise RuntimeError(f"Failed to load session due to JSON decode Error: {e}") - - return [Message(**m) for m in messages] - - -def list_sorted_session_files(session_files_directory: Path) -> dict[str, Path]: - logs = list_session_files(session_files_directory) - return {log.stem: log for log in sorted(logs, key=lambda x: x.stat().st_mtime, reverse=True)} - - -def list_session_files(session_files_directory: Path) -> Iterator[Path]: - return session_files_directory.glob(f"*{SESSION_FILE_SUFFIX}") - - -def session_file_exists(session_files_directory: Path) -> bool: - if not session_files_directory.exists(): - return False - return any(list_session_files(session_files_directory)) - - -def log_messages(file_path: Path, messages: list[Message]) -> None: - with open(file_path, "a") as f: - for message in messages: - json.dump(message.to_dict(), f) - f.write("\n") diff --git a/src/goose/utils/shell.py b/src/goose/utils/shell.py deleted file mode 100644 index d346dd8b..00000000 --- a/src/goose/utils/shell.py +++ /dev/null @@ -1,117 +0,0 @@ -import os -import re -import subprocess -import time -from typing import Mapping, Optional - -from goose.notifier import Notifier -from goose.utils.ask import ask_an_ai -from goose.utils.command_checker import is_dangerous_command -from goose.view import ExchangeView -from rich.prompt import Confirm - - -def keep_unsafe_command_prompt(command: str) -> bool: - message = f"\nWe flagged the command - [bold red]{command}[/] - as potentially unsafe, do you want to proceed?" - return Confirm.ask(message, default=True) - - -def shell( - command: str, - notifier: Notifier, - exchange_view: ExchangeView, - cwd: Optional[str] = None, - env: Optional[Mapping[str, str]] = None, -) -> str: - """Execute a command on the shell - - This handles - """ - if is_dangerous_command(command): - # Stop the notifications so we can prompt - notifier.stop() - if not keep_unsafe_command_prompt(command): - raise RuntimeError( - f"The command {command} was rejected as dangerous by the user." - " Do not proceed further, instead ask for instructions." - ) - notifier.start() - notifier.status("running shell command") - - # Define patterns that might indicate the process is waiting for input - interaction_patterns = [ - r"Do you want to", # Common prompt phrase - r"Enter password", # Password prompt - r"Are you sure", # Confirmation prompt - r"\(y/N\)", # Yes/No prompt - r"Press any key to continue", # Awaiting keypress - r"Waiting for input", # General waiting message - ] - compiled_patterns = [re.compile(pattern, re.IGNORECASE) for pattern in interaction_patterns] - - proc = subprocess.Popen( - command, - shell=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - cwd=cwd, - env=env, - ) - # this enables us to read lines without blocking - os.set_blocking(proc.stdout.fileno(), False) - - # Accumulate the output logs while checking if it might be blocked - output_lines = [] - last_output_time = time.time() - cutoff = 10 - while proc.poll() is None: - notifier.status("running shell command") - line = proc.stdout.readline() - if line: - output_lines.append(line) - last_output_time = time.time() - - # If we see a clear pattern match, we plan to abort - exit_criteria = any(pattern.search(line) for pattern in compiled_patterns) - - # and if we haven't seen a new line in 10+s, check with AI to see if it may be stuck - if not exit_criteria and time.time() - last_output_time > cutoff: - notifier.status("checking on shell status") - response = ask_an_ai( - input="\n".join([command] + output_lines), - prompt=( - "You will evaluate the output of shell commands to see if they may be stuck." - " Look for commands that appear to be awaiting user input, or otherwise running indefinitely (such as a web service)." # noqa - " A command that will take a while, such as downloading resources is okay." # noqa - " return [Yes] if stuck, [No] otherwise." - ), - exchange=exchange_view.processor, - with_tools=False, - ) - exit_criteria = "[yes]" in response.content[0].text.lower() - # We add exponential backoff for how often we check for the command being stuck - cutoff *= 10 - - if exit_criteria: - proc.terminate() - raise ValueError( - f"The command `{command}` looks like it will run indefinitely or is otherwise stuck." - f"You may be able to specify inputs if it applies to this command." - f"Otherwise to enable continued iteration, you'll need to ask the user to run this command in another terminal." # noqa - ) - - # read any remaining lines - while line := proc.stdout.readline(): - output_lines.append(line) - output = "".join(output_lines) - - # Determine the result based on the return code - if proc.returncode == 0: - result = "Command succeeded" - else: - result = f"Command failed with returncode {proc.returncode}" - - # Return the combined result and outputs if we made it this far - return "\n".join([result, output]) diff --git a/src/goose/utils/time_utils.py b/src/goose/utils/time_utils.py deleted file mode 100644 index c9976ff0..00000000 --- a/src/goose/utils/time_utils.py +++ /dev/null @@ -1,5 +0,0 @@ -from datetime import datetime - - -def formatted_time(time: datetime) -> str: - return time.astimezone().isoformat(timespec="seconds") diff --git a/src/goose/view.py b/src/goose/view.py deleted file mode 100644 index 705cde9b..00000000 --- a/src/goose/view.py +++ /dev/null @@ -1,26 +0,0 @@ -from attrs import define -from exchange import Exchange - - -@define -class ExchangeView: - """A read-only view of the underlying Exchange - - - Attributes: - processor: A copy of the exchange configured for high capabilities - accelerator: A copy of the exchange configured for high speed - - """ - - _processor: str - _accelerator: str - _exchange: Exchange - - @property - def processor(self) -> Exchange: - return self._exchange.replace(model=self._processor) - - @property - def accelerator(self) -> Exchange: - return self._exchange.replace(model=self._accelerator) diff --git a/tests/.ruff.toml b/tests/.ruff.toml deleted file mode 100644 index 9accb3c3..00000000 --- a/tests/.ruff.toml +++ /dev/null @@ -1,2 +0,0 @@ -lint.select = ["E", "W", "F", "N"] -line-length = 120 diff --git a/tests/cli/prompt/test_goose_prompt_session.py b/tests/cli/prompt/test_goose_prompt_session.py deleted file mode 100644 index 1c9578fa..00000000 --- a/tests/cli/prompt/test_goose_prompt_session.py +++ /dev/null @@ -1,55 +0,0 @@ -from unittest.mock import patch - -from prompt_toolkit import PromptSession -import pytest -from goose.cli.prompt.goose_prompt_session import GoosePromptSession -from goose.cli.prompt.user_input import PromptAction, UserInput - - -@pytest.fixture -def mock_prompt_session(): - with patch("goose.cli.prompt.goose_prompt_session.PromptSession") as mock_prompt_session: - yield mock_prompt_session - - -def test_get_save_session_name(mock_prompt_session): - mock_prompt_session.return_value.prompt.return_value = "my_session" - goose_prompt_session = GoosePromptSession() - - assert goose_prompt_session.get_save_session_name() == "my_session" - - -def test_get_save_session_name_with_space(mock_prompt_session): - mock_prompt_session.return_value.prompt.return_value = "my_session " - goose_prompt_session = GoosePromptSession() - - assert goose_prompt_session.get_save_session_name() == "my_session" - - -def test_get_user_input_to_continue(): - with patch.object(PromptSession, "prompt", return_value="input_value"): - goose_prompt_session = GoosePromptSession() - - user_input = goose_prompt_session.get_user_input() - - assert user_input == UserInput(PromptAction.CONTINUE, "input_value") - - -@pytest.mark.parametrize("exit_input", ["exit", ":q"]) -def test_get_user_input_to_exit(exit_input, mock_prompt_session): - with patch.object(PromptSession, "prompt", return_value=exit_input): - goose_prompt_session = GoosePromptSession() - - user_input = goose_prompt_session.get_user_input() - - assert user_input == UserInput(PromptAction.EXIT) - - -@pytest.mark.parametrize("error", [EOFError, KeyboardInterrupt]) -def test_get_user_input_to_exit_when_error_occurs(error, mock_prompt_session): - with patch.object(PromptSession, "prompt", side_effect=error): - goose_prompt_session = GoosePromptSession() - - user_input = goose_prompt_session.get_user_input() - - assert user_input == UserInput(PromptAction.EXIT) diff --git a/tests/cli/prompt/test_lexer.py b/tests/cli/prompt/test_lexer.py deleted file mode 100644 index 790bed40..00000000 --- a/tests/cli/prompt/test_lexer.py +++ /dev/null @@ -1,276 +0,0 @@ -from goose.cli.prompt.lexer import ( - PromptLexer, - command_itself, - completion_for_command, - value_for_command, -) -from prompt_toolkit.document import Document - - -# Helper function to create a Document and lexer instance -def create_lexer_and_document(commands, text): - lexer = PromptLexer(commands) - document = Document(text) - return lexer, document - - -# Test cases -def test_lex_document_command(): - lexer, document = create_lexer_and_document(["file"], "/file:example.txt") - tokens = lexer.lex_document(document) - expected_tokens = [("class:command", "/file:"), ("class:parameter", "example.txt")] - assert tokens(0) == expected_tokens - - -def test_lex_document_partial_command(): - lexer, document = create_lexer_and_document(["file"], "/fi") - tokens = lexer.lex_document(document) - expected_tokens = [("class:command", "/fi")] - assert tokens(0) == expected_tokens - - -def test_lex_document_with_text(): - lexer, document = create_lexer_and_document(["file"], "Some text /file:example.txt") - tokens = lexer.lex_document(document) - expected_tokens = [ - ("class:text", "S"), - ("class:text", "o"), - ("class:text", "m"), - ("class:text", "e"), - ("class:text", " "), - ("class:text", "t"), - ("class:text", "e"), - ("class:text", "x"), - ("class:text", "t"), - ("class:text", " "), - ("class:command", "/file:"), - ("class:parameter", "example.txt"), - ] - assert tokens(0) == expected_tokens - - -def test_lex_document_with_command_in_middle(): - lexer, document = create_lexer_and_document(["file"], "Some text /file:example.txt more text") - tokens = lexer.lex_document(document) - expected_tokens = [ - ("class:text", "S"), - ("class:text", "o"), - ("class:text", "m"), - ("class:text", "e"), - ("class:text", " "), - ("class:text", "t"), - ("class:text", "e"), - ("class:text", "x"), - ("class:text", "t"), - ("class:text", " "), - ("class:command", "/file:"), - ("class:parameter", "example.txt"), - ("class:text", " "), - ("class:text", "m"), - ("class:text", "o"), - ("class:text", "r"), - ("class:text", "e"), - ("class:text", " "), - ("class:text", "t"), - ("class:text", "e"), - ("class:text", "x"), - ("class:text", "t"), - ] - actual_tokens = list(tokens(0)) - assert actual_tokens == expected_tokens - - -def test_lex_document_multiple_commands(): - lexer, document = create_lexer_and_document( - ["command", "anothercommand"], - "/command:example1.txt more text /anothercommand:example2.txt", - ) - tokens = lexer.lex_document(document) - expected_tokens = [ - ("class:command", "/command:"), - ("class:parameter", "example1.txt"), - ("class:text", " "), - ("class:text", "m"), - ("class:text", "o"), - ("class:text", "r"), - ("class:text", "e"), - ("class:text", " "), - ("class:text", "t"), - ("class:text", "e"), - ("class:text", "x"), - ("class:text", "t"), - ("class:text", " "), - ("class:command", "/anothercommand:"), - ("class:parameter", "example2.txt"), - ] - actual_tokens = list(tokens(0)) - assert actual_tokens == expected_tokens - - -def test_lex_document_multiple_same_commands(): - lexer, document = create_lexer_and_document( - ["command"], - "/command:example1.txt more text /command:example2.txt", - ) - tokens = lexer.lex_document(document) - expected_tokens = [ - ("class:command", "/command:"), - ("class:parameter", "example1.txt"), - ("class:text", " "), - ("class:text", "m"), - ("class:text", "o"), - ("class:text", "r"), - ("class:text", "e"), - ("class:text", " "), - ("class:text", "t"), - ("class:text", "e"), - ("class:text", "x"), - ("class:text", "t"), - ("class:text", " "), - ("class:command", "/command:"), - ("class:parameter", "example2.txt"), - ] - actual_tokens = list(tokens(0)) - assert actual_tokens == expected_tokens - - -def test_lex_document_two_half_commands(): - lexer, document = create_lexer_and_document( - ["command"], - "/comma /com", - ) - tokens = lexer.lex_document(document) - expected_tokens = [ - ("class:text", "/"), - ("class:text", "c"), - ("class:text", "o"), - ("class:text", "m"), - ("class:text", "m"), - ("class:text", "a"), - ("class:text", " "), - ("class:command", "/com"), - ] - actual_tokens = list(tokens(0)) - assert actual_tokens == expected_tokens - - -def test_lex_document_command_attached_to_pre_string(): - lexer, document = create_lexer_and_document( - ["command"], - "some/command:example.txt", - ) - expected_tokens = [ - ("class:text", "s"), - ("class:text", "o"), - ("class:text", "m"), - ("class:text", "e"), - ("class:text", "/"), - ("class:text", "c"), - ("class:text", "o"), - ("class:text", "m"), - ("class:text", "m"), - ("class:text", "a"), - ("class:text", "n"), - ("class:text", "d"), - ("class:text", ":"), - ("class:text", "e"), - ("class:text", "x"), - ("class:text", "a"), - ("class:text", "m"), - ("class:text", "p"), - ("class:text", "l"), - ("class:text", "e"), - ("class:text", "."), - ("class:text", "t"), - ("class:text", "x"), - ("class:text", "t"), - ] - tokens = lexer.lex_document(document) - actual_tokens = list(tokens(0)) - assert actual_tokens == expected_tokens - - -def test_lex_document_partial_command_attached_to_pre_string(): - lexer, document = create_lexer_and_document( - ["command"], - "some/com", - ) - tokens = lexer.lex_document(document) - expected_tokens = [ - ("class:text", "s"), - ("class:text", "o"), - ("class:text", "m"), - ("class:text", "e"), - ("class:text", "/"), - ("class:text", "c"), - ("class:text", "o"), - ("class:text", "m"), - ] - actual_tokens = list(tokens(0)) - assert actual_tokens == expected_tokens - - -def test_lex_document_no_command(): - lexer, document = create_lexer_and_document([], "Some random text") - tokens = lexer.lex_document(document) - expected_tokens = [("class:text", character) for character in "Some random text"] - actual_tokens = list(tokens(0)) - assert actual_tokens == expected_tokens - - -def test_lex_document_ending_char_of_parameter_is_symbol(): - lexer, document = create_lexer_and_document( - ["command"], - "/command:example.txt/", - ) - expected_tokens = [ - ("class:command", "/command:"), - ("class:parameter", "example.txt/"), - ] - tokens = lexer.lex_document(document) - actual_tokens = list(tokens(0)) - assert actual_tokens == expected_tokens - - -def assert_pattern_matches(pattern, text, expected_group): - matches = pattern.search(text) - assert matches is not None - assert matches.group() == expected_group - - -def test_command_itself(): - pattern = command_itself("file") - assert_pattern_matches(pattern, "/file:example.txt", "/file:") - assert_pattern_matches(pattern, "/file asdf", "/file") - assert_pattern_matches(pattern, "some /file", "/file") - assert_pattern_matches(pattern, "some /file:", "/file:") - assert_pattern_matches(pattern, "/file /file", "/file") - - assert pattern.search("file") is None - assert pattern.search("/anothercommand") is None - - -def test_value_for_command(): - pattern = value_for_command("file") - assert_pattern_matches(pattern, "/file:example.txt", "example.txt") - assert_pattern_matches(pattern, '/file:"example space.txt"', '"example space.txt"') - assert_pattern_matches(pattern, '/file:"example.txt" some other string', '"example.txt"') - assert_pattern_matches(pattern, "something before /file:example.txt", "example.txt") - - # assert no pattern matches when there is no value - assert pattern.search("/file:").group() == "" - assert pattern.search("/file: other").group() == "" - assert pattern.search("/file: ").group() == "" - assert pattern.search("/file other") is None - - -def test_completion_for_command(): - pattern = completion_for_command("file") - assert_pattern_matches(pattern, "/file", "/file") - assert_pattern_matches(pattern, "/fi", "/fi") - assert_pattern_matches(pattern, "before /fi", "/fi") - assert_pattern_matches(pattern, "some /f", "/f") - - assert pattern.search("/file after") is None - assert pattern.search("/ file") is None - assert pattern.search("/file:") is None diff --git a/tests/cli/prompt/test_overwrite_session_prompt.py b/tests/cli/prompt/test_overwrite_session_prompt.py deleted file mode 100644 index 95cf825b..00000000 --- a/tests/cli/prompt/test_overwrite_session_prompt.py +++ /dev/null @@ -1,49 +0,0 @@ -import pytest -from goose.cli.prompt.overwrite_session_prompt import OverwriteSessionPrompt - - -@pytest.fixture -def prompt(): - return OverwriteSessionPrompt() - - -def test_init(prompt): - assert prompt.choices == { - "yes": "Overwrite the existing session", - "no": "Pick a new session name", - "resume": "Resume the existing session", - } - assert prompt.default == "resume" - - -@pytest.mark.parametrize( - "choice, expected", - [ - ("", False), - ("invalid", False), - ("n", True), - ("N", True), - ("no", True), - ("NO", True), - ("r", True), - ("R", True), - ("resume", True), - ("RESUME", True), - ("y", True), - ("Y", True), - ("yes", True), - ("YES", True), - ], -) -def test_check_choice(prompt, choice, expected): - assert prompt.check_choice(choice) == expected - - -def test_instantiation(): - prompt = OverwriteSessionPrompt() - assert prompt.choices == { - "yes": "Overwrite the existing session", - "no": "Pick a new session name", - "resume": "Resume the existing session", - } - assert prompt.default == "resume" diff --git a/tests/cli/prompt/test_prompt_validator.py b/tests/cli/prompt/test_prompt_validator.py deleted file mode 100644 index 380a7dd5..00000000 --- a/tests/cli/prompt/test_prompt_validator.py +++ /dev/null @@ -1,37 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from goose.cli.prompt.prompt_validator import PromptValidator -from prompt_toolkit.validation import ValidationError - - -@pytest.fixture -def validator(): - return PromptValidator() - - -@patch("prompt_toolkit.document.Document.text") -def test_validate_should_not_raise_error_when_input_is_none(document, validator): - try: - validator.validate(create_mock_document(None)) - except Exception as e: - pytest.fail(f"An error was raised: {e}") - - -@patch("prompt_toolkit.document.Document.text", return_value="user typed something") -def test_validate_should_not_raise_error_when_user_has_input(document, validator): - try: - validator.validate(create_mock_document("user typed something")) - except Exception as e: - pytest.fail(f"An error was raised: {e}") - - -def test_validate_should_raise_validation_error_when_user_has_empty_input(validator): - with pytest.raises(ValidationError): - validator.validate(create_mock_document("")) - - -def create_mock_document(text: str) -> MagicMock: - document = MagicMock() - document.text = text - return document diff --git a/tests/cli/prompt/test_user_input.py b/tests/cli/prompt/test_user_input.py deleted file mode 100644 index 029fadfa..00000000 --- a/tests/cli/prompt/test_user_input.py +++ /dev/null @@ -1,15 +0,0 @@ -from goose.cli.prompt.user_input import PromptAction, UserInput - - -def test_user_input_with_action_continue(): - input = UserInput(action=PromptAction.CONTINUE, text="Hello") - assert input.to_continue() is True - assert input.to_exit() is False - assert input.text == "Hello" - - -def test_user_input_with_action_exit(): - input = UserInput(action=PromptAction.EXIT) - assert input.to_continue() is False - assert input.to_exit() is True - assert input.text is None diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py deleted file mode 100644 index 0694034d..00000000 --- a/tests/cli/test_config.py +++ /dev/null @@ -1,94 +0,0 @@ -from unittest.mock import patch - -import pytest -from goose.cli.config import ensure_config, read_config, session_path, write_config -from goose.profile import default_profile - - -@pytest.fixture -def mock_profile_config_path(tmp_path): - with patch("goose.cli.config.PROFILES_CONFIG_PATH", tmp_path / "profiles.yaml") as mock_path: - yield mock_path - - -@pytest.fixture -def mock_default_model_configuration(): - with patch( - "goose.cli.config.default_model_configuration", return_value=("provider", "processor", "accelerator") - ) as mock_default_model_configuration: - yield mock_default_model_configuration - - -def test_read_write_config(mock_profile_config_path, profile_factory): - profiles = { - "profile1": profile_factory({"provider": "providerA"}), - } - write_config(profiles) - - assert read_config() == profiles - - -def test_ensure_config_create_profiles_file_with_default_profile_with_name_default( - mock_profile_config_path, mock_default_model_configuration -): - assert not mock_profile_config_path.exists() - - (profile_name, profile) = ensure_config(name=None) - - expected_profile = default_profile(*mock_default_model_configuration()) - - assert profile_name == "default" - assert profile == expected_profile - assert mock_profile_config_path.exists() - assert read_config() == {"default": expected_profile} - - -def test_ensure_config_create_profiles_file_with_default_profile_with_profile_name( - mock_profile_config_path, mock_default_model_configuration -): - assert not mock_profile_config_path.exists() - - (profile_name, profile) = ensure_config(name="my_profile") - - expected_profile = default_profile(*mock_default_model_configuration()) - - assert profile_name == "my_profile" - assert profile == expected_profile - assert mock_profile_config_path.exists() - assert read_config() == {"my_profile": expected_profile} - - -def test_ensure_config_add_default_profile_when_profile_not_exist( - mock_profile_config_path, profile_factory, mock_default_model_configuration -): - existing_profile = profile_factory({"provider": "providerA"}) - write_config({"profile1": existing_profile}) - - (profile_name, new_profile) = ensure_config(name="my_new_profile") - - expected_profile = default_profile(*mock_default_model_configuration()) - assert profile_name == "my_new_profile" - assert new_profile == expected_profile - assert read_config() == { - "profile1": existing_profile, - "my_new_profile": expected_profile, - } - - -def test_ensure_config_get_existing_profile_not_exist( - mock_profile_config_path, profile_factory, mock_default_model_configuration -): - existing_profile = profile_factory({"provider": "providerA"}) - write_config({"profile1": existing_profile}) - - (profile_name, profile) = ensure_config(name="profile1") - - assert profile_name == "profile1" - assert profile == existing_profile - assert read_config() == { - "profile1": existing_profile, - } - - -def test_session_path(mock_sessions_path): - assert session_path("session1") == mock_sessions_path / "session1.jsonl" diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py deleted file mode 100644 index 67177e03..00000000 --- a/tests/cli/test_main.py +++ /dev/null @@ -1,175 +0,0 @@ -from datetime import datetime -import importlib -from time import time -from unittest.mock import MagicMock, patch - -import click -import pytest -from click.testing import CliRunner -from exchange import Message -from goose.cli.main import cli, goose_cli - - -@pytest.fixture -def mock_print(): - with patch("goose.cli.main.print") as mock_print: - yield mock_print - - -@pytest.fixture -def mock_session_files_path(tmp_path): - with patch("goose.cli.main.SESSIONS_PATH", tmp_path) as session_files_path: - yield session_files_path - - -@pytest.fixture -def mock_session(): - with patch("goose.cli.main.Session") as mock_session_class: - mock_session_instance = MagicMock() - mock_session_class.return_value = mock_session_instance - yield mock_session_class, mock_session_instance - - -def test_session_start_command_with_session_name(mock_session): - mock_session_class, mock_session_instance = mock_session - runner = CliRunner() - runner.invoke(goose_cli, ["session", "start", "session1", "--profile", "default"]) - mock_session_class.assert_called_once_with( - name="session1", profile="default", plan=None, log_level="INFO", tracing=False - ) - mock_session_instance.run.assert_called_once() - - -def test_session_resume_command_with_session_name(mock_session): - mock_session_class, mock_session_instance = mock_session - runner = CliRunner() - runner.invoke(goose_cli, ["session", "resume", "session1", "--profile", "default"]) - mock_session_class.assert_called_once_with(name="session1", profile="default", log_level="INFO") - mock_session_instance.run.assert_called_once() - - -def test_session_resume_command_without_session_name_without_session_files( - mock_print, mock_session_files_path, mock_session -): - _, mock_session_instance = mock_session - runner = CliRunner() - runner.invoke(goose_cli, ["session", "resume"]) - mock_print.assert_called_with("No sessions found.") - mock_session_instance.run.assert_not_called() - - -def test_session_resume_command_without_session_name_use_latest_session( - mock_print, mock_session_files_path, mock_session, create_session_file -): - mock_session_class, mock_session_instance = mock_session - for index, session_name in enumerate(["first", "second"]): - create_session_file([Message.user("Hello1")], mock_session_files_path / f"{session_name}.jsonl", time() + index) - runner = CliRunner() - runner.invoke(goose_cli, ["session", "resume", "--profile", "default"]) - - second_file_path = mock_session_files_path / "second.jsonl" - mock_print.assert_called_once_with(f"Resuming most recent session: second from {second_file_path}") - mock_session_class.assert_called_once_with(name="second", profile="default", log_level="INFO") - mock_session_instance.run.assert_called_once() - - -def test_session_list_command(mock_print, mock_session_files_path, create_session_file): - create_session_file([Message.user("Hello")], mock_session_files_path / "abc.jsonl") - runner = CliRunner() - runner.invoke(goose_cli, ["session", "list"]) - file_time = datetime.fromtimestamp(mock_session_files_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") - mock_print.assert_called_with(f"{file_time} abc") - - -def test_session_clear_command(mock_session_files_path, create_session_file): - for index, session_name in enumerate(["first", "second"]): - create_session_file([Message.user("Hello1")], mock_session_files_path / f"{session_name}.jsonl", time() + index) - runner = CliRunner() - runner.invoke(goose_cli, ["session", "clear", "--keep", "1"]) - - session_files = list(mock_session_files_path.glob("*.jsonl")) - assert len(session_files) == 1 - assert session_files[0].stem == "second" - - -def test_combined_group_option(): - with patch("goose.utils.load_plugins") as mock_load_plugin: - group_option_name = "--describe-commands" - - def option_callback(ctx, *_): - click.echo("Option callback") - ctx.exit() - - mock_group_options = { - "option1": lambda: click.option( - group_option_name, - is_flag=True, - callback=option_callback, - ), - } - - def side_effect_func(param): - if param == "goose.cli.group_option": - return mock_group_options - elif param == "goose.cli.group": - return {} - - mock_load_plugin.side_effect = side_effect_func - - # reload cli after mocking - importlib.reload(importlib.import_module("goose.cli.main")) - import goose.cli.main - - cli = goose.cli.main.cli - - runner = CliRunner() - result = runner.invoke(cli, [group_option_name]) - assert result.exit_code == 0 - - -def test_combined_group_commands(mock_session): - mock_session_class, mock_session_instance = mock_session - runner = CliRunner() - runner.invoke(cli, ["session", "resume", "session1", "--profile", "default"]) - mock_session_class.assert_called_once_with(name="session1", profile="default", log_level="INFO") - mock_session_instance.run.assert_called_once() - - -def test_version_long_option(): - runner = CliRunner() - result = runner.invoke(cli, ["--version"]) - assert result.exit_code == 0 - assert "version" in result.output.lower() - - -def test_version_short_option(): - runner = CliRunner() - result = runner.invoke(cli, ["-V"]) - assert result.exit_code == 0 - assert "version" in result.output.lower() - - -def test_version_subcommand(): - runner = CliRunner() - result = runner.invoke(cli, ["version"]) - assert result.exit_code == 0 - assert "version" in result.output.lower() - - -def test_goose_no_args_print_help(): - runner = CliRunner() - result = runner.invoke(cli, []) - assert result.exit_code == 0 - assert "Usage:" in result.output - assert "Options:" in result.output - assert "Commands:" in result.output - - -def test_moderators_list_command(): - runner = CliRunner() - result = runner.invoke(cli, ["moderators", "list"]) - assert result.exit_code == 0 - assert "Available moderators:" in result.output - assert "passive" in result.output - assert "summarize" in result.output - assert "truncate" in result.output diff --git a/tests/cli/test_session.py b/tests/cli/test_session.py deleted file mode 100644 index 573f72a3..00000000 --- a/tests/cli/test_session.py +++ /dev/null @@ -1,283 +0,0 @@ -import os -from datetime import datetime -from typing import Union -from unittest.mock import MagicMock, mock_open, patch - -import pytest -from exchange import Message, ToolResult, ToolUse -from exchange.observers import ObserverManager -from goose.cli.prompt.goose_prompt_session import GoosePromptSession -from goose.cli.prompt.overwrite_session_prompt import OverwriteSessionPrompt -from goose.cli.prompt.user_input import PromptAction, UserInput -from goose.cli.session import Session -from prompt_toolkit import PromptSession - -SPECIFIED_SESSION_NAME = "mySession" -SESSION_NAME = "test" - - -@pytest.fixture(scope="module", autouse=True) -def set_openai_api_key(): - key = "OPENAI_API_KEY" - value = "test_api_key" - - original_api_key = os.environ.get(key) - os.environ[key] = value - - yield - - if original_api_key is None: - os.environ.pop(key, None) - else: - os.environ[key] = original_api_key - - -@pytest.fixture -@patch.object(PromptSession, "prompt", return_value=SPECIFIED_SESSION_NAME) -def mock_specified_session_name(specified_session_name): - yield specified_session_name - - -@pytest.fixture -@patch("goose.cli.session.create_exchange", name="mock_exchange") -@patch("goose.cli.session.load_profile", name="mock_load_profile") -@patch("goose.cli.session.SessionNotifier", name="mock_session_notifier") -@patch("goose.cli.session.load_provider", name="mock_load_provider") -def create_session_with_mock_configs( - mock_load_provider, - mock_session_notifier, - mock_load_profile, - mock_exchange, - mock_sessions_path, - exchange_factory, - profile_factory, -): - mock_load_provider.return_value = "provider" - mock_session_notifier.return_value = MagicMock() - mock_load_profile.return_value = profile_factory() - mock_exchange.return_value = exchange_factory() - - def create_session(session_attributes: dict = {}): - return Session(**session_attributes) - - return create_session - - -@pytest.fixture -def session_factory(create_session_with_mock_configs): - def factory( - name=SESSION_NAME, - overwrite_prompt=None, - is_existing_session=None, - get_initial_messages=None, - file_opener=open, - ): - session = create_session_with_mock_configs({"name": name}) - session.overwrite_prompt = overwrite_prompt or OverwriteSessionPrompt() - session.is_existing_session = is_existing_session or (lambda _: False) - session._get_initial_messages = get_initial_messages or (lambda: []) - session.file_opener = file_opener - return session - - return factory - - -def test_session_does_not_extend_last_user_text_message_on_init( - create_session_with_mock_configs, mock_sessions_path, create_session_file -): - messages = [Message.user("Hello"), Message.assistant("Hi"), Message.user("Last should be removed")] - create_session_file(messages, mock_sessions_path / f"{SESSION_NAME}.jsonl") - - session = create_session_with_mock_configs({"name": SESSION_NAME}) - print("Messages after session init:", session.exchange.messages) # Debugging line - assert len(session.exchange.messages) == 2 - assert [message.text for message in session.exchange.messages] == ["Hello", "Hi"] - - -def test_session_adds_resume_message_if_last_message_is_tool_result( - create_session_with_mock_configs, mock_sessions_path, create_session_file -): - messages = [ - Message.user("Hello"), - Message(role="assistant", content=[ToolUse(id="1", name="first_tool", parameters={})]), - Message(role="user", content=[ToolResult(tool_use_id="1", output="output")]), - ] - create_session_file(messages, mock_sessions_path / f"{SESSION_NAME}.jsonl") - - session = create_session_with_mock_configs({"name": SESSION_NAME}) - print("Messages after session init:", session.exchange.messages) # Debugging line - assert len(session.exchange.messages) == 4 - assert session.exchange.messages[-1].role == "assistant" - assert session.exchange.messages[-1].text == "I see we were interrupted. How can I help you?" - - -def test_session_removes_tool_use_and_adds_resume_message_if_last_message_is_tool_use( - create_session_with_mock_configs, mock_sessions_path, create_session_file -): - messages = [ - Message.user("Hello"), - Message(role="assistant", content=[ToolUse(id="1", name="first_tool", parameters={})]), - ] - create_session_file(messages, mock_sessions_path / f"{SESSION_NAME}.jsonl") - - session = create_session_with_mock_configs({"name": SESSION_NAME}) - print("Messages after session init:", session.exchange.messages) # Debugging line - assert len(session.exchange.messages) == 2 - assert [message.text for message in session.exchange.messages] == [ - "Hello", - "I see we were interrupted. How can I help you?", - ] - - -def test_process_first_message_return_message(create_session_with_mock_configs): - session = create_session_with_mock_configs() - with patch.object( - GoosePromptSession, "get_user_input", return_value=UserInput(action=PromptAction.CONTINUE, text="Hello") - ): - message = session.process_first_message() - - assert message.text == "Hello" - assert len(session.exchange.messages) == 0 - - -def test_process_first_message_to_exit(create_session_with_mock_configs): - session = create_session_with_mock_configs() - with patch.object(GoosePromptSession, "get_user_input", return_value=UserInput(action=PromptAction.EXIT)): - message = session.process_first_message() - - assert message is None - - -def test_process_first_message_return_last_exchange_message(create_session_with_mock_configs): - session = create_session_with_mock_configs() - session.exchange.messages.append(Message.user("Hi")) - - message = session.process_first_message() - - assert message.text == "Hi" - assert len(session.exchange.messages) == 0 - - -def test_log_log_cost(create_session_with_mock_configs): - session = create_session_with_mock_configs() - mock_logger = MagicMock() - start_time = datetime(2024, 10, 20, 1, 2, 3) - end_time = datetime(2024, 10, 21, 2, 3, 4) - cost_message = "You have used 100 tokens" - with ( - patch("exchange.Exchange.get_token_usage", return_value={}), - patch("goose.cli.session.get_total_cost_message", return_value=cost_message), - patch("goose.cli.session.get_logger", return_value=mock_logger), - ): - session._log_cost(start_time, end_time) - mock_logger.info.assert_called_once_with(cost_message) - - -@patch("goose.cli.session.droid", return_value="generated_session_name") -@patch("goose.cli.session.load_provider") -def test_set_generated_session_name( - mock_load_provider, mock_droid, create_session_with_mock_configs, mock_sessions_path -): - mock_provider = MagicMock() - mock_load_provider.return_value = mock_provider - - session = create_session_with_mock_configs({"name": None}) - - assert session.name == "generated_session_name" - - -@patch("goose.cli.session.is_existing_session", name="mock_is_existing") -@patch("goose.cli.session.Session._prompt_overwrite_session", name="mock_prompt") -def test_existing_session_prompt( - mock_prompt, - mock_is_existing, - create_session_with_mock_configs, -): - session = create_session_with_mock_configs({"name": SESSION_NAME}) - - def check_prompt_behavior( - is_existing: bool, - new_session: Union[bool, None], - should_prompt: bool, - ) -> None: - mock_is_existing.return_value = is_existing - if new_session is None: - session.run() - else: - session.run(new_session=new_session) - - if should_prompt: - mock_prompt.assert_called_once() - else: - mock_prompt.assert_not_called() - mock_prompt.reset_mock() - - check_prompt_behavior(is_existing=True, new_session=None, should_prompt=True) - check_prompt_behavior(is_existing=False, new_session=None, should_prompt=False) - check_prompt_behavior(is_existing=True, new_session=True, should_prompt=True) - check_prompt_behavior(is_existing=False, new_session=False, should_prompt=False) - - -def test_prompt_overwrite_session(session_factory): - def check_overwrite_behavior(choice: str, expected_messages: list[Message]) -> None: - session = session_factory() - - with ( - patch.object(OverwriteSessionPrompt, "ask", return_value=choice), - patch.object(session, "is_existing_session", return_value=True), - patch.object( - session, - "_get_initial_messages", - return_value=[Message.user(text="duck duck"), Message.user(text="goose")], - ), - patch("rich.prompt.Prompt.ask", return_value="new_session_name"), - patch("builtins.open", mock_open()) as mock_file, - ): - session._prompt_overwrite_session() - - if choice in ["y", "yes"]: - mock_file.assert_called_once_with(session.session_file_path, "w") - mock_file().write.assert_called_once_with("") - elif choice in ["n", "no"]: - assert session.name == "new_session_name" - elif choice in ["r", "resume"]: - # this is tested comparing the contents of the array - pass - - # because the messages are created with an id and creation date, we only want to check the text - actual_messages = [message.text for message in session.exchange.messages] - expected_messages = [message.text for message in expected_messages] - assert actual_messages == expected_messages - - check_overwrite_behavior(choice="yes", expected_messages=[]) - check_overwrite_behavior(choice="y", expected_messages=[]) - check_overwrite_behavior(choice="no", expected_messages=[]) - check_overwrite_behavior(choice="n", expected_messages=[]) - check_overwrite_behavior( - choice="resume", - expected_messages=[Message.user(text="duck duck"), Message.user(text="goose")], - ) - check_overwrite_behavior( - choice="r", - expected_messages=[Message.user(text="duck duck"), Message.user(text="goose")], - ) - - -def test_observer_plugin_called(create_session_with_mock_configs): - observer_mock = MagicMock() - observe_wrapper_mock = MagicMock() - observer_mock.observe_wrapper = observe_wrapper_mock - - observer_manager_mock = MagicMock(spec=ObserverManager) - observer_manager_mock._observers = [observer_mock] - - with ( - patch("exchange.observers.ObserverManager.get_instance", return_value=observer_manager_mock), - patch("exchange.Exchange.generate", return_value=Message.assistant("test response")), - ): - session = create_session_with_mock_configs({"name": SESSION_NAME}) - - session.exchange.messages.append(Message.user("hi")) - session.reply() - - observe_wrapper_mock.assert_called_once() diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 97506657..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -import os -from time import time -from unittest.mock import Mock, patch - -import pytest -from exchange import Exchange -from goose.profile import Profile - - -@pytest.fixture -def profile_factory(): - def _create_profile(attributes={}): - profile_attrs = { - "provider": "mock_provider", - "processor": "mock_processor", - "accelerator": "mock_accelerator", - "moderator": "mock_moderator", - "toolkits": [], - } - profile_attrs.update(attributes) - return Profile(**profile_attrs) - - return _create_profile - - -@pytest.fixture -def exchange_factory(): - def _create_exchange(attributes={}): - exchange_attrs = { - "provider": "mock_provider", - "system": "mock_system", - "tools": [], - "moderator": Mock(), - "model": "mock_model", - } - exchange_attrs.update(attributes) - return Exchange(**exchange_attrs) - - return _create_exchange - - -@pytest.fixture -def mock_sessions_path(tmp_path): - with patch("goose.cli.config.SESSIONS_PATH", tmp_path) as mock_path: - yield mock_path - - -@pytest.fixture -def create_session_file(): - def _create_session_file(messages, session_file_path, mtime=time()): - with open(session_file_path, "w") as session_file: - for m in messages: - json.dump(m.to_dict(), session_file) - session_file.write("\n") - session_file.close() - os.utime(session_file_path, (mtime, mtime)) - - return _create_session_file diff --git a/tests/synopsis/test_moderator.py b/tests/synopsis/test_moderator.py deleted file mode 100644 index 4d610cb4..00000000 --- a/tests/synopsis/test_moderator.py +++ /dev/null @@ -1,33 +0,0 @@ -from unittest.mock import patch - -import pytest -from exchange.content import Text -from exchange.message import Message -from goose.synopsis.moderator import Synopsis - - -@pytest.fixture -def mock_exchange(exchange_factory): - exchange = exchange_factory() - exchange.messages.extend( - [ - Message(role="user", content=[Text("Test content for user message")]), - Message(role="assistant", content=[Text("Test content for assistant message")]), - Message(role="user", content=[Text("Another user message")]), - ] - ) - return exchange - - -def test_rewrite_with_tool_use(mock_exchange): - tool_use_message = Message(role="user", content=[Text("Tool use message")]) - mock_exchange.messages.append(tool_use_message) - - with patch.object(Synopsis, "get_synopsis") as mock_get_synopsis: - message = Message(role="synopsis", content=[Text("Updated synopsis")]) - mock_get_synopsis.return_value = message - synopsis = Synopsis() - synopsis.rewrite(mock_exchange) - - # The first message should be replaced, and the rest are cleared - assert mock_exchange.messages == [message] diff --git a/tests/synopsis/test_process_management.py b/tests/synopsis/test_process_management.py deleted file mode 100644 index e166aea9..00000000 --- a/tests/synopsis/test_process_management.py +++ /dev/null @@ -1,62 +0,0 @@ -import pytest -import time -import requests -from goose.synopsis.toolkit import SynopsisDeveloper -from goose.synopsis.system import system - - -class MockNotifier: - def log(self, message): - pass - - def status(self, message): - pass - - -@pytest.fixture -def toolkit(tmpdir): - original_cwd = system.cwd - system.cwd = str(tmpdir) - notifier = MockNotifier() - toolkit = SynopsisDeveloper(notifier=notifier) - - yield toolkit - - # Teardown: cancel all processes and restore original working directory - for process_id in list(system._processes.keys()): - system.cancel_process(process_id) - system.cwd = original_cwd - - -def test_start_process(toolkit): - process_id = toolkit.process_manager(command="start", shell_command="python -m http.server 8000") - assert process_id > 0 - time.sleep(2) # Give the server time to start - - # Check if the server is running - try: - response = requests.get("http://localhost:8000") - assert response.status_code == 200 - except requests.ConnectionError: - pytest.fail("HTTP server did not start successfully") - output = toolkit.process_manager(command="view_output", process_id=process_id) - assert "200" in output - - -def test_list_processes(toolkit): - process_id = toolkit.process_manager(command="start", shell_command="python -m http.server 8001") - processes = toolkit.process_manager(command="list") - assert process_id in processes - assert "python -m http.server 8001" in processes[process_id] - - -def test_cancel_process(toolkit): - process_id = toolkit.process_manager(command="start", shell_command="python -m http.server 8003") - time.sleep(2) # Give the server time to start - - result = toolkit.process_manager(command="cancel", process_id=process_id) - assert result == f"Process {process_id} cancelled" - - # Verify that the process is no longer running - with pytest.raises(ValueError): - toolkit.process_manager(command="view_output", process_id=process_id) diff --git a/tests/synopsis/test_system.py b/tests/synopsis/test_system.py deleted file mode 100644 index fdc94dba..00000000 --- a/tests/synopsis/test_system.py +++ /dev/null @@ -1,93 +0,0 @@ -import os -from unittest.mock import Mock -import pytest -from goose.synopsis.system import OperatingSystem - - -@pytest.fixture -def os_instance(tmpdir): - original_cwd = os.getcwd() - os.chdir(tmpdir) - yield OperatingSystem(cwd=str(tmpdir)) - os.chdir(original_cwd) - - -def test_to_relative(os_instance, tmpdir): - abs_path = os.path.join(tmpdir, "test_file.txt") - rel_path = os_instance.to_relative(abs_path) - assert rel_path == "test_file.txt" - - -def test_remember_forget_file(os_instance, tmpdir): - test_file = tmpdir.join("test_file.txt") - test_file.write("test content") - - os_instance.remember_file(str(test_file)) - assert os_instance.is_active(str(test_file)) - - os_instance.forget_file(str(test_file)) - assert not os_instance.is_active(str(test_file)) - - -def test_active_files(os_instance, tmpdir): - test_file1 = tmpdir.join("test_file1.txt") - test_file2 = tmpdir.join("test_file2.py") - test_file1.write("test content 1") - test_file2.write("test content 2") - - os_instance.remember_file(str(test_file1)) - os_instance.remember_file(str(test_file2)) - - active_files = list(os_instance.active_files) - assert len(active_files) == 2 - assert any(f.path == "test_file1.txt" for f in active_files) - assert any(f.path == "test_file2.py" for f in active_files) - - -def test_info(os_instance): - info = os_instance.info() - assert "os" in info - assert "cwd" in info - assert "shell" in info - - -def test_add_process(os_instance): - process = Mock() - process.pid = 1234 - process.stdout = Mock() - process.stdout.fileno.return_value = 1 - process_id = os_instance.add_process(process) - assert process_id == 1234 - assert 1234 in os_instance._processes - - -def test_get_processes(os_instance): - process1 = Mock() - process1.pid = 1234 - process1.args = "python -m http.server 8000" - process1.stdout = Mock() - process1.stdout.fileno.return_value = 1 - os_instance.add_process(process1) - - process2 = Mock() - process2.pid = 5678 - process2.args = "python script.py" - process2.stdout = Mock() - process2.stdout.fileno.return_value = 2 - os_instance.add_process(process2) - - processes = os_instance.get_processes() - assert processes == {1234: "python -m http.server 8000", 5678: "python script.py"} - - -def test_cancel_process(os_instance): - process = Mock() - process.pid = 1234 - process.stdout = Mock() - process.stdout.fileno.return_value = 1 - os_instance.add_process(process) - - result = os_instance.cancel_process(1234) - assert result is True - assert 1234 not in os_instance._processes - process.terminate.assert_called_once() diff --git a/tests/synopsis/test_toolkit.py b/tests/synopsis/test_toolkit.py deleted file mode 100644 index 29aabf59..00000000 --- a/tests/synopsis/test_toolkit.py +++ /dev/null @@ -1,115 +0,0 @@ -import os -import pytest -from goose.synopsis.toolkit import SynopsisDeveloper -from goose.synopsis.system import system - - -class MockNotifier: - def log(self, message): - pass - - def status(self, message): - pass - - -@pytest.fixture -def toolkit(tmpdir): - original_cwd = os.getcwd() - os.chdir(tmpdir) - system.cwd = str(tmpdir) - notifier = MockNotifier() - toolkit = SynopsisDeveloper(notifier=notifier) - - yield toolkit - - # Teardown: cancel all processes and restore original working directory - for process_id in list(system._processes.keys()): - system.cancel_process(process_id) - os.chdir(original_cwd) - system.cwd = original_cwd - - -def test_shell(toolkit, tmpdir): - result = toolkit.bash(command="echo 'Hello, World!'") - assert "Hello, World!" in result - - -def test_text_editor_read_write_file(toolkit, tmpdir): - test_file = tmpdir.join("test_file.txt") - content = "Test content" - - toolkit.text_editor(command="create", path=str(test_file), file_text=content) - assert test_file.read() == content - - result = toolkit.text_editor(command="view", path=str(test_file)) - assert "Displayed content of" in result - assert system.is_active(str(test_file)) - - -def test_text_editor_patch_file(toolkit, tmpdir): - test_file = tmpdir.join("test_file.txt") - test_file.write("Hello, World!") - - toolkit.text_editor(command="view", path=str(test_file)) # Remember the file - result = toolkit.text_editor(command="str_replace", path=str(test_file), old_str="World", new_str="Universe") - assert "Successfully replaced before with after" in result - assert test_file.read() == "Hello, Universe!" - - -def test_change_dir(toolkit, tmpdir): - subdir = tmpdir.mkdir("subdir") - result = toolkit.bash(working_dir=str(subdir)) - assert str(subdir) in result - assert system.cwd == str(subdir) - - -def test_start_process(toolkit, tmpdir): - process_id = toolkit.process_manager(command="start", shell_command="python -m http.server 8000") - assert process_id > 0 - - # Check if the process is in the list of running processes - processes = toolkit.process_manager(command="list") - assert process_id in processes - assert "python -m http.server 8000" in processes[process_id] - - -def test_list_processes(toolkit, tmpdir): - process_id1 = toolkit.process_manager(command="start", shell_command="python -m http.server 8001") - process_id2 = toolkit.process_manager(command="start", shell_command="python -m http.server 8002") - - processes = toolkit.process_manager(command="list") - assert process_id1 in processes - assert process_id2 in processes - assert "python -m http.server 8001" in processes[process_id1] - assert "python -m http.server 8002" in processes[process_id2] - - -def test_cancel_process(toolkit, tmpdir): - process_id = toolkit.process_manager(command="start", shell_command="python -m http.server 8003") - - result = toolkit.process_manager(command="cancel", process_id=process_id) - assert result == f"Process {process_id} cancelled" - - # Verify that the process is no longer in the list - processes = toolkit.process_manager(command="list") - assert process_id not in processes - - -def test_fetch_web_content(toolkit): - url = "http://example.com" - - result = toolkit.fetch_web_content(url) - assert "html_file_path" in result - assert "text_file_path" in result - - html_file_path = result["html_file_path"] - text_file_path = result["text_file_path"] - - with open(html_file_path, "r") as html_file: - fetched_content = html_file.read() - - assert "Example Domain" in fetched_content - - with open(text_file_path, "r") as html_file: - fetched_content = html_file.read() - assert "Example Domain" in fetched_content diff --git a/tests/test_autocomplete.py b/tests/test_autocomplete.py deleted file mode 100644 index 789b5ec2..00000000 --- a/tests/test_autocomplete.py +++ /dev/null @@ -1,34 +0,0 @@ -import sys -import unittest.mock as mock - -from goose.utils.autocomplete import SUPPORTED_SHELLS, is_autocomplete_installed, setup_autocomplete - - -def test_supported_shells(): - assert SUPPORTED_SHELLS == ["bash", "zsh", "fish"] - - -def test_install_autocomplete(tmp_path): - file = tmp_path / "test_bash_autocomplete" - assert is_autocomplete_installed(file) is False - - file.write_text("_GOOSE_COMPLETE") - assert is_autocomplete_installed(file) is True - - -@mock.patch("sys.stdout") -def test_setup_bash(mocker: mock.MagicMock): - setup_autocomplete("bash", install=False) - sys.stdout.write.assert_called_with('eval "$(_GOOSE_COMPLETE=bash_source goose)"\n') - - -@mock.patch("sys.stdout") -def test_setup_zsh(mocker: mock.MagicMock): - setup_autocomplete("zsh", install=False) - sys.stdout.write.assert_called_with('eval "$(_GOOSE_COMPLETE=zsh_source goose)"\n') - - -@mock.patch("sys.stdout") -def test_setup_fish(mocker: mock.MagicMock): - setup_autocomplete("fish", install=False) - sys.stdout.write.assert_called_with("_GOOSE_COMPLETE=fish_source goose | source\n") diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py deleted file mode 100644 index 9160be1b..00000000 --- a/tests/test_cli_main.py +++ /dev/null @@ -1,21 +0,0 @@ -from click.testing import CliRunner -from goose.cli.main import get_current_shell, shell_completions - - -def test_get_current_shell(mocker): - mocker.patch("os.getenv", return_value="/bin/bash") - assert get_current_shell() == "bash" - - -def test_shell_completions_install_invalid_combination(): - runner = CliRunner() - result = runner.invoke(shell_completions, ["--install", "--generate", "bash"]) - assert result.exit_code != 0 - assert "Only one of --install or --generate can be specified" in result.output - - -def test_shell_completions_install_no_option(): - runner = CliRunner() - result = runner.invoke(shell_completions, ["bash"]) - assert result.exit_code != 0 - assert "One of --install or --generate must be specified" in result.output diff --git a/tests/test_completer.py b/tests/test_completer.py deleted file mode 100644 index 8749975f..00000000 --- a/tests/test_completer.py +++ /dev/null @@ -1,50 +0,0 @@ -from unittest.mock import Mock - -import pytest -from goose.cli.prompt.completer import GoosePromptCompleter -from goose.command.base import Command -from prompt_toolkit.completion import Completion -from prompt_toolkit.document import Document - -# Mock Command class -dummy_command = Mock(spec=Command) - -dummy_command.get_completions = Mock( - return_value=[ - Completion(text="completion1"), - Completion(text="completion2"), - ] -) - -commands_list = {"test_command1": dummy_command, "test_command2": dummy_command} - - -@pytest.fixture -def completer(): - return GoosePromptCompleter(commands=commands_list) - - -def test_get_command_completions(completer): - document = Document(text="/test_command1:input") - completions = list(completer.get_command_completions(document)) - assert len(completions) == 2 - assert completions[0].text == "completion1" - assert completions[1].text == "completion2" - - -def test_get_command_name_completions(completer): - document = Document(text="/test") - completions = list(completer.get_command_name_completions(document)) - print(completions) - assert len(completions) == 2 - assert completions[0].text == "test_command1" - assert completions[1].text == "test_command2" - - -def test_get_completions(completer): - document = Document(text="/test_command1:input") - completions = list(completer.get_completions(document, None)) - print(completions) - assert len(completions) == 2 - assert completions[0].text == "completion1" - assert completions[1].text == "completion2" diff --git a/tests/test_jira.py b/tests/test_jira.py deleted file mode 100644 index 16e23ae4..00000000 --- a/tests/test_jira.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest -from goose.toolkit.jira import Jira - - -@pytest.fixture -def jira_toolkit(): - return Jira(None) - - -def test_jira_system_prompt(jira_toolkit): - prompt = jira_toolkit.system() - print("System Prompt:\n", prompt) - # Ensure Jinja template syntax isn't present in the loaded prompt - # Ensure both installation instructions are present in the prompt - assert "macos" in prompt - assert "On other operating systems or for alternative installation methods" in prompt - - -def test_is_jira_issue(jira_toolkit): - valid_jira_issue = "PROJ-123" - invalid_jira_issue = "INVALID_ISSUE" - # Ensure the regex correctly identifies valid JIRA issues - assert jira_toolkit.is_jira_issue(valid_jira_issue) - assert not jira_toolkit.is_jira_issue(invalid_jira_issue) diff --git a/tests/test_linting.py b/tests/test_linting.py deleted file mode 100644 index cae0d1a7..00000000 --- a/tests/test_linting.py +++ /dev/null @@ -1,11 +0,0 @@ -from goose.toolkit.lint import lint_toolkits - -from goose.toolkit.lint import lint_providers - - -def test_lint_toolkits(): - lint_toolkits() - - -def test_lint_providers(): - lint_providers() diff --git a/tests/test_profile.py b/tests/test_profile.py deleted file mode 100644 index b063937c..00000000 --- a/tests/test_profile.py +++ /dev/null @@ -1,16 +0,0 @@ -from goose.profile import ToolkitSpec, ObserverSpec - - -def test_profile_info(profile_factory): - profile = profile_factory( - { - "provider": "provider", - "processor": "processor", - "toolkits": [ToolkitSpec("developer"), ToolkitSpec("github")], - "observers": [ObserverSpec(name="test.plugin")], - } - ) - assert ( - profile.profile_info() - == "provider:provider, processor:processor toolkits: developer, github observers: test.plugin" - ) diff --git a/tests/toolkit/__init__.py b/tests/toolkit/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/toolkit/test_developer.py b/tests/toolkit/test_developer.py deleted file mode 100644 index 8624ea54..00000000 --- a/tests/toolkit/test_developer.py +++ /dev/null @@ -1,191 +0,0 @@ -from pathlib import Path -from tempfile import TemporaryDirectory -from unittest.mock import MagicMock, Mock - -import pytest -from goose.toolkit.base import Requirements -from goose.toolkit.developer import Developer -from contextlib import contextmanager -import os - - -@contextmanager -def change_dir(new_dir): - """Context manager to temporarily change the current working directory.""" - original_dir = os.getcwd() - os.chdir(new_dir) - try: - yield - finally: - os.chdir(original_dir) - - -@pytest.fixture -def temp_dir(): - with TemporaryDirectory() as temp_dir: - yield Path(temp_dir) - - -@pytest.fixture -def developer_toolkit(): - toolkit = Developer(notifier=MagicMock(), requires=Requirements("")) - - # This mocking ensures that that the safety check is considered a pass in shell calls - toolkit.exchange_view = Mock() - toolkit.exchange_view.processor.replace.return_value = Mock() - toolkit.exchange_view.processor.replace.return_value.messages = [] - toolkit.exchange_view.processor.replace.return_value.add = Mock() - toolkit.exchange_view.processor.replace.return_value.reply.return_value.text = "3" - toolkit.exchange_view.processor.replace.return_value.messages = [Mock()] - - return toolkit - - -def test_fetch_web_content(developer_toolkit): - url = "http://example.com" - - result = developer_toolkit.fetch_web_content(url) - assert "html_file_path" in result - assert "text_file_path" in result - - html_file_path = result["html_file_path"] - text_file_path = result["text_file_path"] - - with open(html_file_path, "r") as html_file: - fetched_content = html_file.read() - - assert "Example Domain" in fetched_content - - with open(text_file_path, "r") as html_file: - fetched_content = html_file.read() - assert "Example Domain" in fetched_content - - -def test_system_prompt_with_goosehints(temp_dir, developer_toolkit): - readme_file = temp_dir / "README.md" - readme_file.write_text("This is from the README.md file.") - - hints_file = temp_dir / ".goosehints" - jinja_template_content = "Hints:\n\n{% include 'README.md' %}\nEnd." - hints_file.write_text(jinja_template_content) - - with change_dir(temp_dir): - system_prompt = developer_toolkit.system() - expected_end = "Hints:\n\nThis is from the README.md file.\nEnd." - assert system_prompt.endswith(expected_end) - - -def test_system_prompt_with_goosehints_from_parent_dir(temp_dir, developer_toolkit): - hints_file = temp_dir / ".goosehints" - hints_file.write_text("This is from the README.md file in parent.") - inner_temp_dir = temp_dir / "inner" - inner_temp_dir.mkdir(parents=True, exist_ok=True) - - with change_dir(inner_temp_dir): - system_prompt = developer_toolkit.system() - expected = "This is from the README.md file in parent." - assert system_prompt.endswith(expected) - - -def test_system_prompt_with_goosehints_only_from_home_dir(temp_dir, developer_toolkit): - readme_file_home = Path.home() / ".config/goose/README.md" - readme_file_home.parent.mkdir(parents=True, exist_ok=True) - readme_file_home.write_text("This is from the README.md file in home.") - - home_hints_file = Path.home() / ".config/goose/.goosehints" - home_jinja_template_content = "Hints from home:\n\n{% include 'README.md' %}\nEnd." - home_hints_file.write_text(home_jinja_template_content) - - try: - with change_dir(temp_dir): - system_prompt = developer_toolkit.system() - expected_content_home = "Hints from home:\n\nThis is from the README.md file in home.\nEnd." - expected_end = f"Hints:\n{expected_content_home}" - assert system_prompt.endswith(expected_end) - finally: - home_hints_file.unlink() - readme_file_home.unlink() - - readme_file = temp_dir / "README.md" - readme_file.write_text("This is from the README.md file.") - - hints_file = temp_dir / ".goosehints" - jinja_template_content = "Hints from local:\n\n{% include 'README.md' %}\nEnd." - hints_file.write_text(jinja_template_content) - - home_hints_file = Path.home() / ".config/goose/.goosehints" - home_jinja_template_content = "Hints from home:\n\n{% include 'README.md' %}\nEnd." - home_hints_file.write_text(home_jinja_template_content) - - home_readme_file = Path.home() / ".config/goose/README.md" - home_readme_file.write_text("This is from the README.md file in home.") - - try: - with change_dir(temp_dir): - system_prompt = developer_toolkit.system() - expected_content_local = "Hints from local:\n\nThis is from the README.md file.\nEnd." - expected_content_home = "Hints from home:\n\nThis is from the README.md file in home.\nEnd." - expected_end = f"Hints:\n{expected_content_local}\n\n{expected_content_home}" - assert system_prompt.endswith(expected_end) - finally: - home_hints_file.unlink() - home_readme_file.unlink() - - tasks = [ - {"description": "Task 1", "status": "planned"}, - {"description": "Task 2", "status": "complete"}, - {"description": "Task 3", "status": "in-progress"}, - ] - updated_tasks = developer_toolkit.update_plan(tasks) - assert updated_tasks == tasks - - -def test_patch_file(temp_dir, developer_toolkit): - test_file = temp_dir / "test.txt" - before_content = "Hello World" - after_content = "Hello Goose" - test_file.write_text(before_content) - developer_toolkit.patch_file(test_file.as_posix(), before_content, after_content) - assert test_file.read_text() == after_content - - -def test_read_file(temp_dir, developer_toolkit): - test_file = temp_dir / "test.txt" - content = "Hello World" - test_file.write_text(content) - read_content = developer_toolkit.read_file(test_file.as_posix()) - assert content in read_content - - -def test_shell(developer_toolkit): - command = "echo Hello World" - result = developer_toolkit.shell(command) - assert "Hello World" in result - - -def test_write_file(temp_dir, developer_toolkit): - test_file = temp_dir / "test.txt" - content = "Hello World" - developer_toolkit.write_file(test_file.as_posix(), content) - assert test_file.read_text() == content - - -def test_write_file_prevent_write_if_changed(temp_dir, developer_toolkit): - test_file = temp_dir / "test.txt" - content = "Hello World" - updated_content = "Hello Universe" - - # Initial write to record the timestamp - developer_toolkit.write_file(test_file.as_posix(), content) - developer_toolkit.read_file(test_file.as_posix()) - - import time - - # Modify file externally to simulate change - time.sleep(1) - test_file.write_text(updated_content) - - # Try to write through toolkit and check for the raised exception - with pytest.raises(RuntimeError, match="has been modified"): - developer_toolkit.write_file(test_file.as_posix(), content) - assert test_file.read_text() == updated_content diff --git a/tests/toolkit/test_google_workspace.py b/tests/toolkit/test_google_workspace.py deleted file mode 100644 index afe5aa3a..00000000 --- a/tests/toolkit/test_google_workspace.py +++ /dev/null @@ -1,135 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest - -from goose.toolkit.google_workspace import GoogleWorkspace -from goose.tools.google_oauth_handler import GoogleOAuthHandler - - -@pytest.fixture -def google_workspace_toolkit(): - return GoogleWorkspace(notifier=MagicMock()) - - -@pytest.fixture -def mock_credentials(): - mock_creds = MagicMock() - mock_creds.token = "mock_token" - return mock_creds - - -def test_google_workspace_init(google_workspace_toolkit): - assert isinstance(google_workspace_toolkit, GoogleWorkspace) - - -@patch.object(GoogleOAuthHandler, "get_credentials") -def test_login(mock_get_credentials, google_workspace_toolkit, mock_credentials): - mock_get_credentials.return_value = mock_credentials - result = google_workspace_toolkit.login() - assert "Successfully authenticated with Google!" in result - assert "Access token: mock_tok..." in result - - -@patch.object(GoogleOAuthHandler, "get_credentials") -def test_login_error(mock_get_credentials, google_workspace_toolkit): - mock_get_credentials.side_effect = ValueError("Test error") - result = google_workspace_toolkit.login() - assert "Error: Test error" in result - - -@patch("goose.toolkit.google_workspace.get_file_paths") -def test_file_paths(mock_get_file_paths): - mock_get_file_paths.return_value = { - "CLIENT_SECRETS_FILE": "/mock/home/path/.config/goose/google_credentials.json", - "TOKEN_FILE": "/mock/home/path/.config/goose/google_oauth_token.json", - } - from goose.toolkit.google_workspace import get_file_paths - - file_paths = get_file_paths() - assert file_paths["CLIENT_SECRETS_FILE"] == "/mock/home/path/.config/goose/google_credentials.json" - assert file_paths["TOKEN_FILE"] == "/mock/home/path/.config/goose/google_oauth_token.json" - - -def test_list_emails(mocker, google_workspace_toolkit): - # Mock get_file_paths - mock_get_file_paths = mocker.patch("goose.toolkit.google_workspace.get_file_paths") - mock_get_file_paths.return_value = { - "CLIENT_SECRETS_FILE": "/mock/home/path/.config/goose/google_credentials.json", - "TOKEN_FILE": "/mock/home/path/.config/goose/google_oauth_token.json", - } - - # Mock GoogleOAuthHandler - mock_google_oauth_handler = mocker.patch("goose.toolkit.google_workspace.GoogleOAuthHandler") - mock_credentials = mocker.MagicMock() - mock_google_oauth_handler.return_value.get_credentials.return_value = mock_credentials - - # Mock GmailClient - mock_gmail_client = mocker.patch("goose.toolkit.google_workspace.GmailClient") - mock_gmail_client.return_value.list_emails.return_value = "mock_emails" - - # Call the method - result = google_workspace_toolkit.list_emails() - - # Assertions - assert result == "mock_emails" - mock_get_file_paths.assert_called_once() - mock_google_oauth_handler.assert_called_once_with( - "/mock/home/path/.config/goose/google_credentials.json", - "/mock/home/path/.config/goose/google_oauth_token.json", - ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/calendar.readonly"], - ) - mock_google_oauth_handler.return_value.get_credentials.assert_called_once() - mock_gmail_client.assert_called_once_with(mock_credentials) - mock_gmail_client.return_value.list_emails.assert_called_once() - - -def test_todays_schedule(mocker, google_workspace_toolkit): - mock_calendar_client = mocker.Mock() - mock_calendar_client.list_events_for_today.return_value = [ - { - "summary": "Test Event 1", - "start": {"dateTime": "2023-05-01T09:00:00"}, - "end": {"dateTime": "2023-05-01T10:00:00"}, - }, - { - "summary": "Test Event 2", - "start": {"dateTime": "2023-05-01T14:00:00"}, - "end": {"dateTime": "2023-05-01T15:00:00"}, - }, - ] - mocker.patch("goose.toolkit.google_workspace.GoogleCalendarClient", return_value=mock_calendar_client) - mocker.patch( - "goose.toolkit.google_workspace.get_file_paths", - return_value={"CLIENT_SECRETS_FILE": "mock_path", "TOKEN_FILE": "mock_path"}, - ) - mocker.patch("goose.toolkit.google_workspace.GoogleOAuthHandler") - - result = google_workspace_toolkit.todays_schedule() - - assert isinstance(result, list) - assert len(result) == 2 - assert result[0]["summary"] == "Test Event 1" - assert result[1]["summary"] == "Test Event 2" - - -def test_list_calendars(mocker, google_workspace_toolkit): - mock_calendar_client = mocker.Mock() - mock_calendar_client.list_calendars.return_value = [ - {"summary": "Calendar 1", "id": "calendar1@example.com"}, - {"summary": "Calendar 2", "id": "calendar2@example.com"}, - ] - mocker.patch("goose.toolkit.google_workspace.GoogleCalendarClient", return_value=mock_calendar_client) - mocker.patch( - "goose.toolkit.google_workspace.get_file_paths", - return_value={"CLIENT_SECRETS_FILE": "mock_path", "TOKEN_FILE": "mock_path"}, - ) - mocker.patch("goose.toolkit.google_workspace.GoogleOAuthHandler") - - result = google_workspace_toolkit.list_calendars() - - assert isinstance(result, list) - assert len(result) == 2 - assert result[0]["summary"] == "Calendar 1" - assert result[1]["summary"] == "Calendar 2" - assert result[0]["id"] == "calendar1@example.com" - assert result[1]["id"] == "calendar2@example.com" diff --git a/tests/toolkit/test_memory.py b/tests/toolkit/test_memory.py deleted file mode 100644 index b9c2a647..00000000 --- a/tests/toolkit/test_memory.py +++ /dev/null @@ -1,143 +0,0 @@ -from unittest.mock import MagicMock -import pytest -from goose.toolkit.memory import Memory - - -@pytest.fixture -def memory_toolkit(tmp_path): - """Create a memory toolkit instance with temporary directories""" - mock_notifier = MagicMock() - toolkit = Memory(notifier=mock_notifier) - # Override memory directories for testing - toolkit.local_memory_dir = tmp_path / ".goose/memory" - toolkit.global_memory_dir = tmp_path / ".config/goose/memory" - toolkit._ensure_memory_dirs() - return toolkit - - -def test_remember_global(memory_toolkit): - """Test storing a memory in global scope""" - result = memory_toolkit.remember("Test memory", "test_category", tags="tag1 tag2", scope="global") - assert "test_category" in result - assert "tag1" in result - assert "tag2" in result - assert "global" in result - - # Verify file content - memory_file = memory_toolkit.global_memory_dir / "test_category.txt" - content = memory_file.read_text() - assert "#tag1 tag2" in content - assert "Test memory" in content - - -def test_remember_local(memory_toolkit): - """Test storing a memory in local scope""" - result = memory_toolkit.remember("Local test", "local_category", tags="local", scope="local") - assert "local_category" in result - assert "local" in result - - # Verify file content - memory_file = memory_toolkit.local_memory_dir / "local_category.txt" - content = memory_file.read_text() - assert "#local" in content - assert "Local test" in content - - -def test_search_by_text(memory_toolkit): - """Test searching memories by text""" - memory_toolkit.remember("Test memory one", "category1", scope="global") - memory_toolkit.remember("Test memory two", "category2", scope="global") - - result = memory_toolkit.search("memory") - assert "Test memory one" in result - assert "Test memory two" in result - - -def test_search_by_tag(memory_toolkit): - """Test searching memories by tag""" - memory_toolkit.remember("Tagged memory", "tagged", tags="findme test", scope="global") - memory_toolkit.remember("Another tagged", "tagged", tags="findme other", scope="global") - - result = memory_toolkit.search("findme") - assert "Tagged memory" in result - assert "Another tagged" in result - - -def test_search_specific_category(memory_toolkit): - """Test searching in a specific category""" - memory_toolkit.remember("Memory in cat1", "cat1", scope="global") - memory_toolkit.remember("Memory in cat2", "cat2", scope="global") - - result = memory_toolkit.search("Memory", category="cat1") - assert "Memory in cat1" in result - assert "Memory in cat2" not in result - - -def test_list_categories(memory_toolkit): - """Test listing memory categories""" - memory_toolkit.remember("Global memory", "global_cat", scope="global") - memory_toolkit.remember("Local memory", "local_cat", scope="local") - - result = memory_toolkit.list_categories() - assert "global_cat" in result - assert "local_cat" in result - - # Test scope filtering - global_only = memory_toolkit.list_categories(scope="global") - assert "global_cat" in global_only - assert "local_cat" not in global_only - - -def test_forget_category(memory_toolkit): - """Test removing a category""" - memory_toolkit.remember("Memory to forget", "forget_me", scope="global") - assert (memory_toolkit.global_memory_dir / "forget_me.txt").exists() - - result = memory_toolkit.forget_category("forget_me", scope="global") - assert "Successfully removed" in result - assert not (memory_toolkit.global_memory_dir / "forget_me.txt").exists() - - -def test_invalid_category_name(memory_toolkit): - """Test that invalid category names are sanitized""" - result = memory_toolkit.remember("Test memory", "test/category!", tags="tag", scope="global") - assert "test_category_" in result - - # Verify file was created with sanitized name - files = list(memory_toolkit.global_memory_dir.glob("*.txt")) - assert len(files) == 1 - assert "test_category_" in files[0].name - - -def test_system_prompt_includes_memories(memory_toolkit): - """Test that the system prompt includes existing memories""" - # Add some test memories - memory_toolkit.remember("Global test memory", "global_cat", tags="tag1 tag2", scope="global") - memory_toolkit.remember("Local test memory", "local_cat", tags="tag3", scope="local") - - system_prompt = memory_toolkit.system() - - # Check that the base prompt is included - assert "I have access to a memory system" in system_prompt - - # Check that memories are included - assert "Global memories:" in system_prompt - assert "Category: global_cat" in system_prompt - assert "Global test memory" in system_prompt - assert "[tags: tag1 tag2]" in system_prompt - - assert "Local memories:" in system_prompt - assert "Category: local_cat" in system_prompt - assert "Local test memory" in system_prompt - assert "[tags: tag3]" in system_prompt - - -def test_system_prompt_empty_memories(memory_toolkit): - """Test that the system prompt handles no existing memories gracefully""" - system_prompt = memory_toolkit.system() - - # Check that the base prompt is included - assert "I have access to a memory system" in system_prompt - - # Check that empty memory state is handled - assert "No existing memories found" in system_prompt diff --git a/tests/toolkit/test_utils.py b/tests/toolkit/test_utils.py deleted file mode 100644 index b4cd22da..00000000 --- a/tests/toolkit/test_utils.py +++ /dev/null @@ -1,58 +0,0 @@ -from goose.toolkit.utils import parse_plan - - -def test_parse_plan_simple(): - plan_str = "Here is python repo\n-use uv\n-do not use poetry\n\nNow you should:\n\n-Open a file\n-Run a test" - expected_result = { - "kickoff_message": "Here is python repo\n-use uv\n-do not use poetry\n\nNow you should:", - "tasks": ["Open a file", "Run a test"], - } - assert expected_result == parse_plan(plan_str) - - -def test_parse_plan_multiple_groups(): - plan_str = ( - "Here is python repo\n" - "-use uv\n" - "-do not use poetry\n\n" - "Now you should:\n\n" - "-Open a file\n" - "-Run a test\n\n" - "Now actually follow the steps:\n" - "-Step1\n" - "-Step2" - ) - expected_result = { - "kickoff_message": ( - "Here is python repo\n" - "-use uv\n" - "-do not use poetry\n\n" - "Now you should:\n\n" - "-Open a file\n" - "-Run a test\n\n" - "Now actually follow the steps:" - ), - "tasks": ["Step1", "Step2"], - } - assert expected_result == parse_plan(plan_str) - - -def test_parse_plan_empty_tasks(): - plan_str = "Here is python repo" - expected_result = {"kickoff_message": "Here is python repo", "tasks": []} - assert expected_result == parse_plan(plan_str) - - -def test_parse_plan_empty_kickoff_message(): - plan_str = "-task1\n-task2" - expected_result = {"kickoff_message": "", "tasks": ["task1", "task2"]} - assert expected_result == parse_plan(plan_str) - - -def test_parse_plan_with_numbers(): - plan_str = "Here is python repo\nNow you should:\n\n-1 Open a file\n-2 Run a test" - expected_result = { - "kickoff_message": "Here is python repo\nNow you should:", - "tasks": ["1 Open a file", "2 Run a test"], - } - assert expected_result == parse_plan(plan_str) diff --git a/tests/toolkit/test_web_browser.py b/tests/toolkit/test_web_browser.py deleted file mode 100644 index 0d072005..00000000 --- a/tests/toolkit/test_web_browser.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest -from unittest.mock import MagicMock -from goose.toolkit.web_browser import BrowserToolkit - - -# Mock the webdriver -@pytest.fixture -def mock_driver(mocker): - mocker.patch("selenium.webdriver.Chrome") - mocker.patch("selenium.webdriver.Firefox") - - driver_mock = MagicMock() - - mocker.patch.object(BrowserToolkit, "_initialize_driver", return_value=None) - - return driver_mock - - -def test_html_content_extraction(mock_driver): - mock_notifier = MagicMock() - toolkit = BrowserToolkit(notifier=mock_notifier) - toolkit.driver = mock_driver - mock_driver.current_url = "http://example.com" - mock_driver.page_source = "TestPage" - - cached_html_path = toolkit.get_html_content() - - # Read from the cached HTML file and assert its content - with open(cached_html_path, "r", encoding="utf-8") as file: - html_content = file.read() - - assert html_content == "TestPage" diff --git a/tests/utils/test_ask.py b/tests/utils/test_ask.py deleted file mode 100644 index b7bd8269..00000000 --- a/tests/utils/test_ask.py +++ /dev/null @@ -1,136 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from exchange import Exchange, CheckpointData -from goose.utils.ask import ask_an_ai, clear_exchange, replace_prompt - - -# tests for `ask_an_ai` -def test_ask_an_ai_empty_input(): - """Test that function raises TypeError if input is empty.""" - exchange = MagicMock(spec=Exchange) - with pytest.raises(TypeError): - ask_an_ai("", exchange) - - -def test_ask_an_ai_no_history(): - """Test the no_history functionality.""" - exchange = MagicMock(spec=Exchange) - with patch("goose.utils.ask.clear_exchange") as mock_clear: - ask_an_ai("Test input", exchange, no_history=True) - mock_clear.assert_called_once_with(exchange) - - -def test_ask_an_ai_prompt_replacement(): - """Test that the prompt is replaced if provided.""" - exchange = MagicMock(spec=Exchange) - prompt = "New prompt" - - with patch("goose.utils.ask.replace_prompt") as mock_replace_prompt: - # Configure the mock to return a new mock object with the same spec - modified_exchange = MagicMock(spec=Exchange) - mock_replace_prompt.return_value = modified_exchange - - ask_an_ai("Test input", exchange, prompt=prompt, no_history=False) - - # Check if replace_prompt was called correctly - mock_replace_prompt.assert_called_once_with(exchange, prompt) - - # Assert that the modified exchange was returned correctly - assert mock_replace_prompt.return_value is modified_exchange, "Should return the modified exchange mock" - - -def test_ask_an_ai_exchange_usage(): - """Test that the exchange adds and processes the message correctly.""" - exchange = MagicMock(spec=Exchange) - input_text = "Test input" - message_mock = MagicMock(return_value="Mocked Message") - - with patch("goose.utils.ask.Message.user", new=message_mock): - ask_an_ai(input_text, exchange, no_history=False) - - # Assert that Message.user was called with the correct input - message_mock.assert_called_once_with(input_text) - - # Assert that exchange.add was called with the mocked message - exchange.add.assert_called_once_with("Mocked Message") - exchange.reply.assert_called_once() - - -def test_ask_an_ai_return_value(): - """Test that the function returns the correct reply.""" - exchange = MagicMock(spec=Exchange) - expected_reply = "AI response" - exchange.reply.return_value = expected_reply - result = ask_an_ai("Test input", exchange, no_history=False) - assert result == expected_reply, "Function should return the reply from the exchange." - - -# tests for `clear_exchange` -def test_clear_exchange_without_tools(): - """Test clearing messages and checkpoints but not tools.""" - # Arrange - exchange = MagicMock(spec=Exchange) - - # Act - new_exchange = clear_exchange(exchange, clear_tools=False) - - # Assert - exchange.replace.assert_called_once_with(messages=[], checkpoint_data=CheckpointData()) - assert new_exchange == exchange.replace.return_value, "Should return the modified exchange" - - -def test_clear_exchange_with_tools(): - """Test clearing messages, checkpoints, and tools.""" - # Arrange - exchange = MagicMock(spec=Exchange) - - # Act - new_exchange = clear_exchange(exchange, clear_tools=True) - - # Assert - exchange.replace.assert_called_once_with(messages=[], checkpoint_data=CheckpointData(), tools=()) - assert new_exchange == exchange.replace.return_value, "Should return the modified exchange with tools cleared" - - -def test_clear_exchange_return_value(): - """Test that the returned value is a new exchange object.""" - # Arrange - exchange = MagicMock(spec=Exchange) - new_exchange_mock = MagicMock(spec=Exchange) - exchange.replace.return_value = new_exchange_mock - - # Act - new_exchange = clear_exchange(exchange, clear_tools=False) - - # Assert - assert new_exchange == new_exchange_mock, "Returned exchange should be the new exchange instance" - - -# tests for `replace_prompt` -def test_replace_prompt(): - """Test that the system prompt is correctly replaced.""" - # Arrange - exchange = MagicMock(spec=Exchange) - prompt = "New system prompt" - - # Act - new_exchange = replace_prompt(exchange, prompt) - - # Assert - exchange.replace.assert_called_once_with(system=prompt) - assert new_exchange == exchange.replace.return_value, "Should return the modified exchange with the new prompt" - - -def test_replace_prompt_return_value(): - """Test that the returned value is a new exchange object.""" - # Arrange - exchange = MagicMock(spec=Exchange) - expected_new_exchange = MagicMock(spec=Exchange) - exchange.replace.return_value = expected_new_exchange - - # Act - new_exchange = replace_prompt(exchange, "Another prompt") - - # Assert - assert new_exchange == expected_new_exchange, "Returned exchange should be the new exchange instance" diff --git a/tests/utils/test_check_shell_command.py b/tests/utils/test_check_shell_command.py deleted file mode 100644 index 08749f73..00000000 --- a/tests/utils/test_check_shell_command.py +++ /dev/null @@ -1,50 +0,0 @@ -import pytest -from goose.utils.command_checker import add_dangerous_command_patterns, is_dangerous_command - - -@pytest.mark.parametrize( - "command", - [ - "rm -rf /", - "git push origin master", - "sudo reboot", - "mv /etc/passwd /tmp/", - "chmod 777 /etc/passwd", - "chown root:root /etc/passwd", - "mkfs -t ext4 /dev/sda1", - "systemctl stop nginx", - "reboot", - "shutdown now", - "cat ~/.hello.txt", - "cat ~/.config/example.txt", - "pkill -f gradle", - "fuser -k -n tcp 80", - ], -) -def test_dangerous_commands(command): - assert is_dangerous_command(command) - - -@pytest.mark.parametrize( - "command", - [ - "ls -la", - 'echo "Hello World"', - "cp ~/folder/file.txt /tmp/", - "echo hello > ~/toplevel/sublevel.txt", - "cat hello.txt", - "cat ~/config/example.txt", - "ls -la path/to/visible/file", - "echo 'file.with.dot.txt'", - ], -) -def test_safe_commands(command): - assert not is_dangerous_command(command) - - -def test_add_dangerous_patterns(): - add_dangerous_command_patterns(["echo hello"]) - assert is_dangerous_command("echo hello") - - # and that the original commands are still flagged - assert is_dangerous_command("rm -rf /") diff --git a/tests/utils/test_cost_calculator.py b/tests/utils/test_cost_calculator.py deleted file mode 100644 index eae08f69..00000000 --- a/tests/utils/test_cost_calculator.py +++ /dev/null @@ -1,76 +0,0 @@ -from datetime import datetime, timezone -from unittest.mock import MagicMock, patch - -import pytest -from exchange.providers.base import Usage -from goose.utils._cost_calculator import _calculate_cost, get_total_cost_message - -SESSION_NAME = "test_session" -START_TIME = datetime(2024, 10, 20, 1, 2, 3, tzinfo=timezone.utc) -END_TIME = datetime(2024, 10, 21, 2, 3, 4, tzinfo=timezone.utc) - - -@pytest.fixture -def start_time(): - mock_start_time = MagicMock(spec=datetime) - mock_start_time.astimezone.return_value = START_TIME - return mock_start_time - - -@pytest.fixture -def end_time(): - mock_end_time = MagicMock(spec=datetime) - mock_end_time.astimezone.return_value = END_TIME - return mock_end_time - - -@pytest.fixture -def mock_prices(): - prices = {"gpt-4o": (5.00, 15.00), "gpt-4o-mini": (0.150, 0.600)} - with patch("goose.utils._cost_calculator.PRICES", prices) as mock_prices: - yield mock_prices - - -def test_calculate_cost(mock_prices): - cost = _calculate_cost("gpt-4o", Usage(input_tokens=10000, output_tokens=600, total_tokens=10600)) - assert cost == 0.059 - - -def test_get_total_cost_message(mock_prices, start_time, end_time): - message = get_total_cost_message( - { - "gpt-4o": Usage(input_tokens=10000, output_tokens=600, total_tokens=10600), - "gpt-4o-mini": Usage(input_tokens=3000000, output_tokens=4000000, total_tokens=7000000), - }, - SESSION_NAME, - start_time, - end_time, - ) - expected_message = ( - "Session name: test_session | Cost for model gpt-4o Usage(input_tokens=10000, output_tokens=600," - " total_tokens=10600): $0.06\n" - "Session name: test_session | Cost for model gpt-4o-mini Usage(input_tokens=3000000, output_tokens=4000000, " - "total_tokens=7000000): $2.85\n" - "2024-10-20T01:02:03+00:00 - 2024-10-21T02:03:04+00:00 | Session name: test_session | Total cost: $2.91" - ) - assert message == expected_message - - -def test_get_total_cost_message_with_non_available_pricing(mock_prices, start_time, end_time): - message = get_total_cost_message( - { - "non_pricing_model": Usage(input_tokens=10000, output_tokens=600, total_tokens=10600), - "gpt-4o-mini": Usage(input_tokens=3000000, output_tokens=4000000, total_tokens=7000000), - }, - SESSION_NAME, - start_time, - end_time, - ) - expected_message = ( - "Session name: test_session | Cost for model non_pricing_model Usage(input_tokens=10000, output_tokens=600," - " total_tokens=10600): Not available\n" - + "Session name: test_session | Cost for model gpt-4o-mini Usage(input_tokens=3000000, output_tokens=4000000," - " total_tokens=7000000): $2.85\n" - + "2024-10-20T01:02:03+00:00 - 2024-10-21T02:03:04+00:00 | Session name: test_session | Total cost: $2.85" - ) - assert message == expected_message diff --git a/tests/utils/test_create_exchange.py b/tests/utils/test_create_exchange.py deleted file mode 100644 index 62fdde5f..00000000 --- a/tests/utils/test_create_exchange.py +++ /dev/null @@ -1,151 +0,0 @@ -import os -from unittest.mock import MagicMock, patch - -from exchange.exchange import Exchange -from exchange.invalid_choice_error import InvalidChoiceError -from exchange.providers.base import MissingProviderEnvVariableError -import pytest - -from goose.notifier import Notifier -from goose.profile import Profile -from goose.utils._create_exchange import create_exchange - -TEST_PROFILE = MagicMock(spec=Profile) -TEST_EXCHANGE = MagicMock(spec=Exchange) -TEST_NOTIFIER = MagicMock(spec=Notifier) - - -@pytest.fixture -def mock_print(): - with patch("goose.utils._create_exchange.print") as mock_print: - yield mock_print - - -@pytest.fixture -def mock_prompt(): - with patch("goose.utils._create_exchange.prompt") as mock_prompt: - yield mock_prompt - - -@pytest.fixture -def mock_confirm(): - with patch("goose.utils._create_exchange.confirm") as mock_confirm: - yield mock_confirm - - -@pytest.fixture -def mock_sys_exit(): - with patch("sys.exit") as mock_exit: - yield mock_exit - - -@pytest.fixture -def mock_keyring_get_password(): - with patch("keyring.get_password") as mock_get_password: - yield mock_get_password - - -@pytest.fixture -def mock_keyring_set_password(): - with patch("keyring.set_password") as mock_set_password: - yield mock_set_password - - -def test_create_exchange_success(mock_print): - with patch("goose.utils._create_exchange.build_exchange", return_value=TEST_EXCHANGE): - assert create_exchange(profile=TEST_PROFILE, notifier=TEST_NOTIFIER) == TEST_EXCHANGE - - -def test_create_exchange_fail_with_invalid_choice_error(mock_print, mock_sys_exit): - expected_error = InvalidChoiceError( - attribute_name="provider", attribute_value="wrong_provider", available_values=["openai"] - ) - with patch("goose.utils._create_exchange.build_exchange", side_effect=expected_error): - create_exchange(profile=TEST_PROFILE, notifier=TEST_NOTIFIER) - - assert "Unknown provider: wrong_provider. Available providers: openai" in mock_print.call_args_list[0][0][0] - mock_sys_exit.assert_called_once_with(1) - - -class TestWhenProviderEnvVarNotFound: - API_KEY_ENV_VAR = "OPENAI_API_KEY" - API_KEY_ENV_VALUE = "api_key_value" - PROVIDER_NAME = "openai" - SERVICE_NAME = "goose" - EXPECTED_ERROR = MissingProviderEnvVariableError(env_variable=API_KEY_ENV_VAR, provider=PROVIDER_NAME) - - def test_create_exchange_get_api_key_from_keychain( - self, mock_print, mock_sys_exit, mock_keyring_get_password, mock_keyring_set_password - ): - self._clean_env() - with patch("goose.utils._create_exchange.build_exchange", side_effect=[self.EXPECTED_ERROR, TEST_EXCHANGE]): - mock_keyring_get_password.return_value = self.API_KEY_ENV_VALUE - - assert create_exchange(profile=TEST_PROFILE, notifier=TEST_NOTIFIER) == TEST_EXCHANGE - - assert os.environ[self.API_KEY_ENV_VAR] == self.API_KEY_ENV_VALUE - mock_keyring_get_password.assert_called_once_with(self.SERVICE_NAME, self.API_KEY_ENV_VAR) - mock_print.assert_called_once_with( - f"Using {self.API_KEY_ENV_VAR} value for {self.PROVIDER_NAME} from your keychain" - ) - mock_sys_exit.assert_not_called() - mock_keyring_set_password.assert_not_called() - - def test_create_exchange_ask_api_key_and_user_set_in_keychain( - self, mock_prompt, mock_confirm, mock_sys_exit, mock_keyring_get_password, mock_keyring_set_password, mock_print - ): - self._clean_env() - with patch("goose.utils._create_exchange.build_exchange", side_effect=[self.EXPECTED_ERROR, TEST_EXCHANGE]): - mock_keyring_get_password.return_value = None - mock_prompt.return_value = self.API_KEY_ENV_VALUE - mock_confirm.return_value = True - - assert create_exchange(profile=TEST_NOTIFIER, notifier=TEST_NOTIFIER) == TEST_EXCHANGE - - assert os.environ[self.API_KEY_ENV_VAR] == self.API_KEY_ENV_VALUE - mock_keyring_set_password.assert_called_once_with( - self.SERVICE_NAME, self.API_KEY_ENV_VAR, self.API_KEY_ENV_VALUE - ) - mock_confirm.assert_called_once_with( - f"Would you like to save the {self.API_KEY_ENV_VAR} value to your keychain?" - ) - mock_print.assert_called_once_with( - f"Saved {self.API_KEY_ENV_VAR} to your key_chain. " - + f"service_name: goose, user_name: {self.API_KEY_ENV_VAR}" - ) - mock_sys_exit.assert_not_called() - - def test_create_exchange_ask_api_key_and_user_not_set_in_keychain( - self, mock_prompt, mock_confirm, mock_sys_exit, mock_keyring_get_password, mock_keyring_set_password - ): - self._clean_env() - with patch("goose.utils._create_exchange.build_exchange", side_effect=[self.EXPECTED_ERROR, TEST_EXCHANGE]): - mock_keyring_get_password.return_value = None - mock_prompt.return_value = self.API_KEY_ENV_VALUE - mock_confirm.return_value = False - - assert create_exchange(profile=TEST_NOTIFIER, notifier=TEST_NOTIFIER) == TEST_EXCHANGE - - assert os.environ[self.API_KEY_ENV_VAR] == self.API_KEY_ENV_VALUE - mock_keyring_set_password.assert_not_called() - mock_sys_exit.assert_not_called() - - def test_create_exchange_fails_when_user_not_provide_api_key( - self, mock_prompt, mock_confirm, mock_sys_exit, mock_keyring_get_password, mock_print - ): - self._clean_env() - with patch("goose.utils._create_exchange.build_exchange", side_effect=self.EXPECTED_ERROR): - mock_keyring_get_password.return_value = None - mock_prompt.return_value = None - mock_confirm.return_value = False - - create_exchange(profile=TEST_NOTIFIER, notifier=TEST_NOTIFIER) - - assert ( - "Please set the required environment variable to continue." - in mock_print.call_args_list[0][0][0].renderable - ) - mock_sys_exit.assert_called_once_with(1) - - def _clean_env(self): - os.environ.pop(self.API_KEY_ENV_VAR, None) diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py deleted file mode 100644 index e6473954..00000000 --- a/tests/utils/test_file_utils.py +++ /dev/null @@ -1,192 +0,0 @@ -from unittest.mock import patch - -import pytest -from goose.utils.file_utils import ( - create_extensions_list, - create_language_weighting, -) # Adjust the import path as necessary - - -# tests for `create_extensions_list` -def test_create_extensions_list_valid_input(): - """Test with valid input and multiple file extensions.""" - project_root = "/fake/project/root" - max_n = 3 - files = [ - "/fake/project/root/file1.py", - "/fake/project/root/file2.py", - "/fake/project/root/file3.md", - "/fake/project/root/file4.md", - "/fake/project/root/file5.txt", - "/fake/project/root/file6.py", - "/fake/project/root/file7.md", - ] - - with patch("goose.utils.file_utils.create_file_list", return_value=files): - extensions = create_extensions_list(project_root, max_n) - assert extensions == [".py", ".md", ".txt"], "Should return the top 3 extensions in the correct order" - - -def test_create_extensions_list_zero_max_n(): - """Test that a ValueError is raised when max_n is 0.""" - project_root = "/fake/project/root" - max_n = 0 - - with pytest.raises(ValueError, match="Number of file extensions must be greater than 0"): - create_extensions_list(project_root, max_n) - - -def test_create_extensions_list_no_files(): - """Test with a project root that contains no files.""" - project_root = "/fake/project/root" - max_n = 3 - - with patch("goose.utils.file_utils.create_file_list", return_value=[]): - extensions = create_extensions_list(project_root, max_n) - assert extensions == [], "Should return an empty list when no files are present" - - -def test_create_extensions_list_fewer_extensions_than_max_n(): - """Test when there are fewer unique extensions than max_n.""" - project_root = "/fake/project/root" - max_n = 5 - files = [ - "/fake/project/root/file1.py", - "/fake/project/root/file2.py", - "/fake/project/root/file3.md", - ] - - with patch("goose.utils.file_utils.create_file_list", return_value=files): - extensions = create_extensions_list(project_root, max_n) - assert extensions == [".py", ".md"], "Should return all available extensions when fewer than max_n" - - -def test_create_extensions_list_files_without_extensions(): - """Test that files without extensions are ignored.""" - project_root = "/fake/project/root" - max_n = 3 - files = [ - "/fake/project/root/file1", - "/fake/project/root/file2.py", - "/fake/project/root/file3", - "/fake/project/root/file4.md", - ] - - with patch("goose.utils.file_utils.create_file_list", return_value=files): - extensions = create_extensions_list(project_root, max_n) - assert extensions == [".py", ".md"], "Should ignore files without extensions" - - -# tests for `create_language_weighting` -def test_create_language_weighting_normal_case(): - """Test the function with multiple files and different sizes.""" - files = [ - "/fake/project/file1.py", - "/fake/project/file2.py", - "/fake/project/file3.md", - "/fake/project/file4.txt", - ] - - sizes = { - "/fake/project/file1.py": 100, - "/fake/project/file2.py": 200, - "/fake/project/file3.md": 50, - "/fake/project/file4.txt": 150, - } - - # Mocking os.path.getsize to return different sizes for different files - with patch("os.path.getsize") as mock_getsize: - mock_getsize.side_effect = lambda file: sizes[file] - - result = create_language_weighting(files) - - total = sum(sizes.values()) - - expected_result = { - ".py": 300 / total * 100, # 300 out of 600 total - ".txt": 150 / total * 100, # 150 out of 600 total - ".md": 50 / total * 100, # 50 out of 600 total - } - - # Check if the result matches the expected output - assert result[".py"] == pytest.approx(expected_result.get(".py"), 0.01) - assert result[".txt"] == pytest.approx(expected_result.get(".txt"), 0.01) - assert result[".md"] == pytest.approx(expected_result.get(".md"), 0.01) - - -def test_create_language_weighting_no_files(): - """Test the function when no files are provided.""" - files = [] - - result = create_language_weighting(files) - assert result == {}, "Should return an empty dictionary when no files are provided" - - -def test_create_language_weighting_files_without_extensions(): - """Test the function when files have no extensions.""" - files = [ - "/fake/project/file1", - "/fake/project/file2", - ] - - with patch("os.path.getsize", return_value=100): - result = create_language_weighting(files) - - assert result == {}, "Should return an empty dictionary when files have no extensions" - - -def test_create_language_weighting_zero_total_size(): - """Test the function when all files have a size of 0.""" - files = [ - "/fake/project/file1.py", - "/fake/project/file2.py", - ] - - with patch("os.path.getsize", return_value=0): - result = create_language_weighting(files) - - assert result == {".py": 0} - - -def test_create_language_weighting_single_file(): - """Test the function with a single file.""" - files = [ - "/fake/project/file1.py", - ] - - with patch("os.path.getsize", return_value=100): - result = create_language_weighting(files) - - assert result == {".py": 100.0}, "Should return 100% for the single file's extension" - - -def test_create_language_weighting_mixed_extensions(): - """Test the function with files of mixed extensions and sizes.""" - files = [ - "/fake/project/file1.py", - "/fake/project/file2.py", - "/fake/project/file3.md", - "/fake/project/file4.txt", - "/fake/project/file5.md", - ] - - with patch("os.path.getsize") as mock_getsize: - mock_getsize.side_effect = lambda file: { - "/fake/project/file1.py": 100, - "/fake/project/file2.py": 100, - "/fake/project/file3.md": 200, - "/fake/project/file4.txt": 300, - "/fake/project/file5.md": 100, - }[file] - - result = create_language_weighting(files) - - expected_result = { - ".txt": 37.5, # 300 out of 800 total - ".md": 37.5, # 300 out of 800 total - ".py": 25.0, # 200 out of 800 total - } - - assert result[".txt"] == pytest.approx(expected_result.get(".txt"), 0.01) - assert result[".md"] == pytest.approx(expected_result.get(".md"), 0.01) - assert result[".py"] == pytest.approx(expected_result.get(".py"), 0.01) diff --git a/tests/utils/test_session_file.py b/tests/utils/test_session_file.py deleted file mode 100644 index 60b25505..00000000 --- a/tests/utils/test_session_file.py +++ /dev/null @@ -1,99 +0,0 @@ -import os -from pathlib import Path -from unittest.mock import patch - -import pytest -from goose.utils.session_file import ( - is_empty_session, - list_sorted_session_files, - read_from_file, - read_or_create_file, - session_file_exists, -) - - -@pytest.fixture -def file_path(tmp_path): - return tmp_path / "test_file.jsonl" - - -def test_read_from_file_non_existing_file(tmp_path): - with pytest.raises(FileNotFoundError): - read_from_file(tmp_path / "no_existing.json") - - -def test_read_from_file_non_jsonl_file(file_path): - file_path.write_text("Hello World") - with pytest.raises(RuntimeError): - read_from_file(file_path) - - -def test_read_or_create_file_when_file_not_exist(tmp_path): - file_path = tmp_path / "no_existing.json" - - assert read_or_create_file(file_path) == [] - assert os.path.exists(file_path) - - -def test_list_sorted_session_files(tmp_path): - session_files_directory = tmp_path / "session_files_dir" - session_files_directory.mkdir() - file_names = ["file1", "file2", "file3"] - created_session_files = [create_session_file(session_files_directory, file_name) for file_name in file_names] - - sorted_files = list_sorted_session_files(session_files_directory) - assert sorted_files == { - "file3": created_session_files[2], - "file2": created_session_files[1], - "file1": created_session_files[0], - } - - -def test_list_sorted_session_without_session_files(tmp_path): - session_files_directory = tmp_path / "session_files_dir" - - sorted_files = list_sorted_session_files(session_files_directory) - assert sorted_files == {} - - -def test_session_file_exists_return_false_when_directory_does_not_exist(tmp_path): - session_files_directory = tmp_path / "session_files_dir" - assert not session_file_exists(session_files_directory) - - -def test_session_file_exists_return_false_when_no_session_file_exists(tmp_path): - session_files_directory = tmp_path / "session_files_dir" - session_files_directory.mkdir() - assert not session_file_exists(session_files_directory) - - -def test_session_file_exists_return_true_when_session_file_exists(tmp_path): - session_files_directory = tmp_path / "session_files_dir" - session_files_directory.mkdir() - create_session_file(session_files_directory, "session1") - assert session_file_exists(session_files_directory) - - -def create_session_file(file_path, file_name) -> Path: - file = file_path / f"{file_name}.jsonl" - file.touch() - return file - - -@patch("pathlib.Path.is_file", return_value=True, name="mock_is_file") -@patch("pathlib.Path.stat", name="mock_stat") -def test_is_empty_session(mock_stat, mock_is_file): - mock_stat.return_value.st_size = 0 - assert is_empty_session(Path("empty_file.json")) - - -@patch("pathlib.Path.is_file", return_value=True, name="mock_is_file") -@patch("pathlib.Path.stat", name="mock_stat") -def test_is_not_empty_session(mock_stat, mock_is_file): - mock_stat.return_value.st_size = 100 - assert not is_empty_session(Path("non_empty_file.json")) - - -@patch("pathlib.Path.is_file", return_value=False, name="mock_is_file") -def test_is_not_empty_session_file_not_found(mock_is_file): - assert not is_empty_session(Path("file_not_found.json")) diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py deleted file mode 100644 index b7a9f992..00000000 --- a/tests/utils/test_utils.py +++ /dev/null @@ -1,63 +0,0 @@ -import string - -import pytest -from goose.utils import droid, ensure, ensure_list, load_plugins - - -class MockClass: - def __init__(self, name): - self.name = name - - def __eq__(self, other): - return self.name == other.name - - -def test_load_plugins(): - plugins = load_plugins("exchange.provider") - assert isinstance(plugins, dict) - assert len(plugins) > 0 - - -def test_ensure_with_class(): - mock_class = MockClass("foo") - assert ensure(MockClass)(mock_class) == mock_class - - -def test_ensure_with_dictionary(): - mock_class = ensure(MockClass)({"name": "foo"}) - assert mock_class == MockClass("foo") - - -def test_ensure_with_invalid_dictionary(): - with pytest.raises(TypeError): - ensure(MockClass)({"age": "foo"}) - - -def test_ensure_with_list(): - mock_class = ensure(MockClass)(["foo"]) - assert mock_class == MockClass("foo") - - -def test_ensure_with_invalid_list(): - with pytest.raises(TypeError): - ensure(MockClass)(["foo", "bar"]) - - -def test_ensure_with_value(): - mock_class = ensure(MockClass)("foo") - assert mock_class == MockClass("foo") - - -def test_ensure_list(): - obj_list = ensure_list(MockClass)(["foo", "bar"]) - assert obj_list == [MockClass("foo"), MockClass("bar")] - - -def test_droid(): - result = droid() - assert isinstance(result, str) - assert len(result) == 4 - for character in [result[i] for i in [0, 2]]: - assert character in string.ascii_lowercase, "should be in lower case" - for character in [result[i] for i in [1, 3]]: - assert character in string.digits, "should be a digit" diff --git a/ui/desktop/.env b/ui/desktop/.env new file mode 100644 index 00000000..68502576 --- /dev/null +++ b/ui/desktop/.env @@ -0,0 +1,4 @@ +VITE_START_EMBEDDED_SERVER=yes +GOOSE_PROVIDER__TYPE=openai +GOOSE_PROVIDER__HOST=https://api.openai.com +GOOSE_PROVIDER__MODEL=gpt-4o \ No newline at end of file diff --git a/ui/desktop/.gitignore b/ui/desktop/.gitignore new file mode 100644 index 00000000..a4518409 --- /dev/null +++ b/ui/desktop/.gitignore @@ -0,0 +1,4 @@ +node_modules +.vite/ +out +src/bin/goosed \ No newline at end of file diff --git a/ui/desktop/.goosehints b/ui/desktop/.goosehints new file mode 100644 index 00000000..ef6a9acb --- /dev/null +++ b/ui/desktop/.goosehints @@ -0,0 +1,14 @@ +You are an expert programmer in electron, with typescript, electron forge and vite and vercel AI sdk, who is teaching another developer who is experienced but not always familiar with these technologies for this desktop app. + + +Key Principles +- Write clear, concise, and idiomatic code with accurate examples. +- Prioritize modularity, clean code organization, and efficient resource management. +- ask the user to verify the UI, and try to test it as well. + +Look at package.json for how to build and run (eg npm start if checking with user) +./src has most of the code + +To validate changes: + +`npm run test-e2e` is a good way to get a feedback loop \ No newline at end of file diff --git a/ui/desktop/.npmrc b/ui/desktop/.npmrc new file mode 100644 index 00000000..214c29d1 --- /dev/null +++ b/ui/desktop/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/ui/desktop/.prettierignore b/ui/desktop/.prettierignore new file mode 100644 index 00000000..d1752038 --- /dev/null +++ b/ui/desktop/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +out +*.lock \ No newline at end of file diff --git a/ui/desktop/.prettierrc.json b/ui/desktop/.prettierrc.json new file mode 100644 index 00000000..a813c658 --- /dev/null +++ b/ui/desktop/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false +} \ No newline at end of file diff --git a/ui/desktop/README.md b/ui/desktop/README.md new file mode 100644 index 00000000..d0f15ae8 --- /dev/null +++ b/ui/desktop/README.md @@ -0,0 +1,36 @@ +# Goose App + +Mac (and maybe windows?) app for Goose. + +``` +git clone git@github.com:block/goose.git +cd goose/ui/desktop +npm install +npm start +``` + +# Building notes + +This is an electron forge app, using vite and react.js. `gooosed` runs as multi process binaries on each window/tab similar to chrome. + +see `package.json`: + +`npm run bundle:default` will give you a Goose.app/zip which is signed/notarized but only if you setup the env vars as per `forge.config.ts` (you can empty out the section on osxSign if you don't want to sign it) - this will have all defaults. + +`npm run bundle:preconfigured` will make a Goose.app/zip signed and notarized, but use the following: + +```python + f" process.env.GOOSE_PROVIDER__TYPE = '{os.getenv("GOOSE_BUNDLE_TYPE")}';", + f" process.env.GOOSE_PROVIDER__HOST = '{os.getenv("GOOSE_BUNDLE_HOST")}';", + f" process.env.GOOSE_PROVIDER__MODEL = '{os.getenv("GOOSE_BUNDLE_MODEL")}';" +``` + +This allows you to set for example GOOSE_PROVIDER__TYPE to be "databricks" by default if you want (so when people start Goose.app - they will get that out of the box). There is no way to set an api key in that bundling as that would be a terrible idea, so only use providers that can do oauth (like databricks can), otherwise stick to default goose. + + +# Runninng with goosed server from source + +Set `VITE_START_EMBEDDED_SERVER=yes` to no in `.env. +Run `cargo run -p goose-server` from parent dir. +`npm run start` will then run against this. +You can try server directly with `./test.sh` diff --git a/ui/desktop/add-macos-cert.sh b/ui/desktop/add-macos-cert.sh new file mode 100755 index 00000000..6da80041 --- /dev/null +++ b/ui/desktop/add-macos-cert.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh + +KEY_CHAIN=build.keychain +CERTIFICATE_P12=certificate.p12 + +# Recreate the certificate from the secure environment variable +echo $CERTIFICATE_OSX_APPLICATION | base64 --decode > $CERTIFICATE_P12 + +#create a keychain +security create-keychain -p actions $KEY_CHAIN + +# Make the keychain the default so identities are found +security default-keychain -s $KEY_CHAIN + +# Unlock the keychain +security unlock-keychain -p actions $KEY_CHAIN + +security import $CERTIFICATE_P12 -k $KEY_CHAIN -P $CERTIFICATE_PASSWORD -T /usr/bin/codesign; + +security set-key-partition-list -S apple-tool:,apple: -s -k actions $KEY_CHAIN + +# remove certs +rm -fr *.p12 \ No newline at end of file diff --git a/ui/desktop/components.json b/ui/desktop/components.json new file mode 100644 index 00000000..3c147bdd --- /dev/null +++ b/ui/desktop/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/main.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/ui/desktop/entitlements.plist b/ui/desktop/entitlements.plist new file mode 100644 index 00000000..856e308d --- /dev/null +++ b/ui/desktop/entitlements.plist @@ -0,0 +1,30 @@ + + + + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.documents.read-write + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.screen-recording + + com.apple.security.automation.apple-events + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.device.camera + + com.apple.security.device.microphone + + + \ No newline at end of file diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js new file mode 100644 index 00000000..758f3925 --- /dev/null +++ b/ui/desktop/eslint.config.js @@ -0,0 +1,138 @@ +const js = require('@eslint/js'); +const tsParser = require('@typescript-eslint/parser'); +const tsPlugin = require('@typescript-eslint/eslint-plugin'); +const reactPlugin = require('eslint-plugin-react'); +const reactHooksPlugin = require('eslint-plugin-react-hooks'); + +// Custom rule to detect window.location.href usage +const noWindowLocationHref = { + meta: { + type: 'problem', + docs: { + description: 'Disallow direct usage of window.location.href in Electron apps', + recommended: true, + }, + }, + create(context) { + return { + AssignmentExpression(node) { + if ( + node.left.type === 'MemberExpression' && + node.left.object.type === 'MemberExpression' && + node.left.object.object.type === 'Identifier' && + node.left.object.object.name === 'window' && + node.left.object.property.name === 'location' && + node.left.property.name === 'href' + ) { + context.report({ + node, + message: 'Do not use window.location.href directly in Electron apps. Use React Router\'s navigate() instead.' + }); + } + } + }; + } +}; + +module.exports = [ + js.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + // Electron/Node.js globals + process: 'readonly', + __dirname: 'readonly', + require: 'readonly', + module: 'readonly', + // Browser globals + window: 'readonly', + document: 'readonly', + navigator: 'readonly', + localStorage: 'readonly', + fetch: 'readonly', + console: 'readonly', + setTimeout: 'readonly', + clearInterval: 'readonly', + setInterval: 'readonly', + CustomEvent: 'readonly', + HTMLElement: 'readonly', + HTMLInputElement: 'readonly', + HTMLTextAreaElement: 'readonly', + HTMLButtonElement: 'readonly', + HTMLDivElement: 'readonly', + FileList: 'readonly', + FileReader: 'readonly', + DOMParser: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + Response: 'readonly', + ReadableStream: 'readonly', + AbortController: 'readonly', + RequestCredentials: 'readonly', + HeadersInit: 'readonly', + KeyboardEvent: 'readonly', + MouseEvent: 'readonly', // Add MouseEvent + Node: 'readonly', // Add Node + React: 'readonly', + handleAction: 'readonly', + requestAnimationFrame: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + 'react': reactPlugin, + 'react-hooks': reactHooksPlugin, + 'custom': { + rules: { + 'no-window-location-href': noWindowLocationHref + } + } + }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...reactPlugin.configs.recommended.rules, + ...reactHooksPlugin.configs.recommended.rules, + // Customize rules + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', // We use TypeScript for prop validation + 'react/no-unknown-property': ['error', { + ignore: ['dark:fill'] // Allow Tailwind dark mode syntax + }], + 'react/no-unescaped-entities': 'off', // Allow quotes in JSX + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_' + }], + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + '@typescript-eslint/ban-types': ['error', { + types: { + Object: { + message: 'Use object instead', + fixWith: 'object', + }, + }, + }], + '@typescript-eslint/no-var-requires': 'warn', // Downgrade to warning for Electron main process + 'no-undef': 'error', + 'no-useless-catch': 'warn', + 'custom/no-window-location-href': 'error' + }, + settings: { + react: { + version: 'detect', + }, + }, + }, +]; \ No newline at end of file diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts new file mode 100644 index 00000000..73e2d074 --- /dev/null +++ b/ui/desktop/forge.config.ts @@ -0,0 +1,93 @@ +const { FusesPlugin } = require('@electron-forge/plugin-fuses'); +const { FuseV1Options, FuseVersion } = require('@electron/fuses'); + +let cfg = { + asar: true, + extraResource: ['src/bin', 'src/images'], + icon: 'src/images/icon', + osxSign: { + entitlements: 'entitlements.plist', + 'entitlements-inherit': 'entitlements.plist', + 'gatekeeper-assess': false, + hardenedRuntime: true, + identity: 'Developer ID Application: Michael Neale (W2L75AE9HQ)', + }, + osxNotarize: { + appleId: process.env['APPLE_ID'], + appleIdPassword: process.env['APPLE_ID_PASSWORD'], + teamId: process.env['APPLE_TEAM_ID'] + }, + protocols: [ + { + name: "GooseProtocol", // The macOS CFBundleURLName + schemes: ["goose"] // The macOS CFBundleURLSchemes array + } + ] +} + +if (process.env['APPLE_ID'] === undefined) { + delete cfg.osxNotarize; + delete cfg.osxSign; +} + +module.exports = { + packagerConfig: cfg, + rebuildConfig: {}, + makers: [ + { + name: '@electron-forge/maker-squirrel', + config: {}, + }, + { + name: '@electron-forge/maker-zip', + platforms: ['darwin'], + }, + { + name: '@electron-forge/maker-deb', + config: {}, + }, + { + name: '@electron-forge/maker-rpm', + config: {}, + }, + ], + plugins: [ + { + name: '@electron-forge/plugin-vite', + config: { + // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc. + // If you are familiar with Vite configuration, it will look really familiar. + build: [ + { + // `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`. + entry: 'src/main.ts', + config: 'vite.main.config.ts', + target: 'main', + }, + { + entry: 'src/preload.js', + config: 'vite.preload.config.ts', + target: 'preload', + }, + ], + renderer: [ + { + name: 'main_window', + config: 'vite.renderer.config.ts', + }, + ], + }, + }, + // Fuses are used to enable/disable various Electron functionality + // at package time, before code signing the application + new FusesPlugin({ + version: FuseVersion.V1, + [FuseV1Options.RunAsNode]: false, + [FuseV1Options.EnableCookieEncryption]: true, + [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, + [FuseV1Options.EnableNodeCliInspectArguments]: false, + [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, + [FuseV1Options.OnlyLoadAppFromAsar]: true, + }), + ], +}; diff --git a/ui/desktop/forge.env.d.ts b/ui/desktop/forge.env.d.ts new file mode 100644 index 00000000..9700e0ae --- /dev/null +++ b/ui/desktop/forge.env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/desktop/helper-scripts/README.md b/ui/desktop/helper-scripts/README.md new file mode 100644 index 00000000..e49d5583 --- /dev/null +++ b/ui/desktop/helper-scripts/README.md @@ -0,0 +1,5 @@ +Put `goosey` in your $PATH if you want to launch via: + +`goosey .` + +Will open goose GUI from any path you specify \ No newline at end of file diff --git a/ui/desktop/helper-scripts/goosey b/ui/desktop/helper-scripts/goosey new file mode 100755 index 00000000..0241ce73 --- /dev/null +++ b/ui/desktop/helper-scripts/goosey @@ -0,0 +1,41 @@ +#!/bin/bash + +# Get the absolute path of the current directory +current_dir="$(pwd)" + +# If an argument is provided, use it as the directory +if [ $# -gt 0 ]; then + # Handle tilde expansion and relative paths + if [[ "$1" == "~"* ]]; then + # Replace ~ with $HOME + dir="${1/#\~/$HOME}" + elif [[ "$1" == "." ]]; then + # Use absolute path as is + dir="$current_dir" + elif [[ "$1" == "."* ]]; then + # Convert relative path to absolute + dir="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" + else + # Convert relative path to absolute + dir="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" + fi +else + # Use current directory if no argument provided + dir="$current_dir" +fi + +echo $dir + +# On macOS, use the .app bundle +if [[ "$OSTYPE" == "darwin"* ]]; then + # Assuming Goose.app is installed in /Applications + if [ -d "/Applications/Goose.app" ]; then + /Applications/Goose.app/Contents/MacOS/Goose --args --dir "$dir" + else + echo "Error: Goose.app not found in /Applications" + exit 1 + fi +else + # For Linux, assuming the binary is installed in the PATH + goose-app --dir "$dir" +fi diff --git a/ui/desktop/index.html b/ui/desktop/index.html new file mode 100644 index 00000000..babfafba --- /dev/null +++ b/ui/desktop/index.html @@ -0,0 +1,24 @@ + + + + + Goose + + + + +
+ + + diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json new file mode 100644 index 00000000..c9c8448e --- /dev/null +++ b/ui/desktop/package-lock.json @@ -0,0 +1,15421 @@ +{ + "name": "goose-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "goose-app", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai": "^0.0.72", + "@ai-sdk/ui-utils": "^1.0.2", + "@radix-ui/react-accordion": "^1.2.2", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-icons": "^1.3.1", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.5", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/themes": "^3.1.5", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/react-syntax-highlighter": "^15.5.13", + "ai": "^3.4.33", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "electron-log": "^5.2.2", + "electron-squirrel-startup": "^1.0.1", + "express": "^4.21.1", + "framer-motion": "^11.11.11", + "lucide-react": "^0.454.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-icons": "^5.3.0", + "react-markdown": "^9.0.1", + "react-router-dom": "^6.28.0", + "react-select": "^5.9.0", + "react-syntax-highlighter": "^15.6.1", + "react-toastify": "^8.0.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "unist-util-visit": "^5.0.0" + }, + "devDependencies": { + "@electron-forge/cli": "^7.5.0", + "@electron-forge/maker-deb": "^7.5.0", + "@electron-forge/maker-rpm": "^7.5.0", + "@electron-forge/maker-squirrel": "^7.5.0", + "@electron-forge/maker-zip": "^7.5.0", + "@electron-forge/plugin-auto-unpack-natives": "^7.5.0", + "@electron-forge/plugin-fuses": "^7.5.0", + "@electron-forge/plugin-vite": "^7.5.0", + "@electron/fuses": "^1.8.0", + "@eslint/js": "^8.56.0", + "@tailwindcss/typography": "^0.5.15", + "@types/cors": "^2.8.17", + "@types/electron-squirrel-startup": "^1.0.2", + "@types/express": "^5.0.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "electron": "33.1.0", + "eslint": "^8.56.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "husky": "^8.0.0", + "lint-staged": "^15.4.1", + "postcss": "^8.4.47", + "prettier": "^3.4.2", + "tailwindcss": "^3.4.14", + "vite": "^5.0.12" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "0.0.72", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-0.0.72.tgz", + "integrity": "sha512-IKsgxIt6KJGkEHyMp975xW5VPmetwhI8g9H6dDmwvemBB41IRQa78YMNttiJqPcgmrZX2QfErOICv1gQvZ1gZg==", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "@ai-sdk/provider-utils": "1.0.22" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "0.0.26", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-0.0.26.tgz", + "integrity": "sha512-dQkfBDs2lTYpKM8389oopPdQgIU007GQyCbuPPrV+K6MtSII3HBfE0stUIMXUb44L+LK1t6GXPP7wjSzjO6uKg==", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-1.0.22.tgz", + "integrity": "sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ==", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "eventsource-parser": "^1.1.2", + "nanoid": "^3.3.7", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/react": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-0.0.70.tgz", + "integrity": "sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==", + "dependencies": { + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/ui-utils": "0.0.50", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/react/node_modules/@ai-sdk/ui-utils": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-0.0.50.tgz", + "integrity": "sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "@ai-sdk/provider-utils": "1.0.22", + "json-schema": "^0.4.0", + "secure-json-parse": "^2.7.0", + "zod-to-json-schema": "^3.23.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/solid": { + "version": "0.0.54", + "resolved": "https://registry.npmjs.org/@ai-sdk/solid/-/solid-0.0.54.tgz", + "integrity": "sha512-96KWTVK+opdFeRubqrgaJXoNiDP89gNxFRWUp0PJOotZW816AbhUf4EnDjBjXTLjXL1n0h8tGSE9sZsRkj9wQQ==", + "dependencies": { + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/ui-utils": "0.0.50" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "solid-js": "^1.7.7" + }, + "peerDependenciesMeta": { + "solid-js": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/solid/node_modules/@ai-sdk/ui-utils": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-0.0.50.tgz", + "integrity": "sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "@ai-sdk/provider-utils": "1.0.22", + "json-schema": "^0.4.0", + "secure-json-parse": "^2.7.0", + "zod-to-json-schema": "^3.23.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/svelte": { + "version": "0.0.57", + "resolved": "https://registry.npmjs.org/@ai-sdk/svelte/-/svelte-0.0.57.tgz", + "integrity": "sha512-SyF9ItIR9ALP9yDNAD+2/5Vl1IT6kchgyDH8xkmhysfJI6WrvJbtO1wdQ0nylvPLcsPoYu+cAlz1krU4lFHcYw==", + "dependencies": { + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/ui-utils": "0.0.50", + "sswr": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/svelte/node_modules/@ai-sdk/ui-utils": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-0.0.50.tgz", + "integrity": "sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "@ai-sdk/provider-utils": "1.0.22", + "json-schema": "^0.4.0", + "secure-json-parse": "^2.7.0", + "zod-to-json-schema": "^3.23.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/ui-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.1.0.tgz", + "integrity": "sha512-ETXwdHaHwzC7NIehbthDFGwsTFk+gNtRL/lm85nR4WDFvvYQptoM/7wTANs0p0H7zumB3Ep5hKzv0Encu8vSRw==", + "dependencies": { + "@ai-sdk/provider": "1.0.4", + "@ai-sdk/provider-utils": "2.1.0", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/ui-utils/node_modules/@ai-sdk/provider": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.0.4.tgz", + "integrity": "sha512-lJi5zwDosvvZER3e/pB8lj1MN3o3S7zJliQq56BRr4e9V3fcRyFtwP0JRxaRS5vHYX3OJ154VezVoQNrk0eaKw==", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/ui-utils/node_modules/@ai-sdk/provider-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.1.0.tgz", + "integrity": "sha512-rBUabNoyB25PBUjaiMSk86fHNSCqTngNZVvXxv8+6mvw47JX5OexW+ZHRsEw8XKTE8+hqvNFVzctaOrRZ2i9Zw==", + "dependencies": { + "@ai-sdk/provider": "1.0.4", + "eventsource-parser": "^3.0.0", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/ui-utils/node_modules/eventsource-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", + "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@ai-sdk/vue": { + "version": "0.0.59", + "resolved": "https://registry.npmjs.org/@ai-sdk/vue/-/vue-0.0.59.tgz", + "integrity": "sha512-+ofYlnqdc8c4F6tM0IKF0+7NagZRAiqBJpGDJ+6EYhDW8FHLUP/JFBgu32SjxSxC6IKFZxEnl68ZoP/Z38EMlw==", + "dependencies": { + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/ui-utils": "0.0.50", + "swrv": "^1.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "vue": "^3.3.4" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/vue/node_modules/@ai-sdk/ui-utils": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-0.0.50.tgz", + "integrity": "sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "@ai-sdk/provider-utils": "1.0.22", + "json-schema": "^0.4.0", + "secure-json-parse": "^2.7.0", + "zod-to-json-schema": "^3.23.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz", + "integrity": "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.5.tgz", + "integrity": "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==", + "dependencies": { + "@babel/parser": "^7.26.5", + "@babel/types": "^7.26.5", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", + "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.5.tgz", + "integrity": "sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==", + "dependencies": { + "@babel/types": "^7.26.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", + "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", + "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.5.tgz", + "integrity": "sha512-rkOSPOw+AXbgtwUga3U4u8RpoK9FEFWBNAlTpcnkLFjL5CT+oyHNuUUC/xx6XefEJ16r38r8Bc/lfp6rYuHeJQ==", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.5", + "@babel/parser": "^7.26.5", + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.5", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.5.tgz", + "integrity": "sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@electron-forge/cli": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.6.0.tgz", + "integrity": "sha512-5G7rBbvTb4HJDiCuhncBzNaRj1e1dEmrk6jExpziqv4Y8p9b+nxfdOjsjWu0hvAl4k2V65Rnm1uEkAA7MmlZOQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.electron-forge-cli?utm_medium=referral&utm_source=npm_fund" + } + ], + "dependencies": { + "@electron-forge/core": "7.6.0", + "@electron-forge/shared-types": "7.6.0", + "@electron/get": "^3.0.0", + "chalk": "^4.0.0", + "commander": "^4.1.1", + "debug": "^4.3.1", + "fs-extra": "^10.0.0", + "listr2": "^7.0.2", + "semver": "^7.2.1" + }, + "bin": { + "electron-forge": "dist/electron-forge.js", + "electron-forge-vscode-nix": "script/vscode.sh", + "electron-forge-vscode-win": "script/vscode.cmd" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/core": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-7.6.0.tgz", + "integrity": "sha512-DgkjpoK+SPExNTLZL1v81zl0RswQWvMXkMnMqZYf0/S/KHKTXWsoE9KTzr8fDGpiG3nUJXWMqHyny9zLoUdKXQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.electron-forge-core?utm_medium=referral&utm_source=npm_fund" + } + ], + "dependencies": { + "@electron-forge/core-utils": "7.6.0", + "@electron-forge/maker-base": "7.6.0", + "@electron-forge/plugin-base": "7.6.0", + "@electron-forge/publisher-base": "7.6.0", + "@electron-forge/shared-types": "7.6.0", + "@electron-forge/template-base": "7.6.0", + "@electron-forge/template-vite": "7.6.0", + "@electron-forge/template-vite-typescript": "7.6.0", + "@electron-forge/template-webpack": "7.6.0", + "@electron-forge/template-webpack-typescript": "7.6.0", + "@electron-forge/tracer": "7.6.0", + "@electron/get": "^3.0.0", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.3.1", + "fast-glob": "^3.2.7", + "filenamify": "^4.1.0", + "find-up": "^5.0.0", + "fs-extra": "^10.0.0", + "got": "^11.8.5", + "interpret": "^3.1.1", + "listr2": "^7.0.2", + "lodash": "^4.17.20", + "log-symbols": "^4.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "rechoir": "^0.8.0", + "resolve-package": "^1.0.1", + "semver": "^7.2.1", + "source-map-support": "^0.5.13", + "sudo-prompt": "^9.1.1", + "username": "^5.1.0", + "yarn-or-npm": "^3.0.1" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/core-utils": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/core-utils/-/core-utils-7.6.0.tgz", + "integrity": "sha512-7XVKHPI87p558kVen280yB1UC2cVGHvrMfnPFv4zm3TQHEVaKWKW+5y+UZsKUnGAukNlahHWuHF/1S8dRCJNEg==", + "dev": true, + "dependencies": { + "@electron-forge/shared-types": "7.6.0", + "@electron/rebuild": "^3.7.0", + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.3.1", + "find-up": "^5.0.0", + "fs-extra": "^10.0.0", + "log-symbols": "^4.0.0", + "semver": "^7.2.1", + "yarn-or-npm": "^3.0.1" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/maker-base": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-7.6.0.tgz", + "integrity": "sha512-GrVYhiA/g0NXrI13LcXrT+JKLlq8kkYyO6w0jQORqDFeRSLRoLhrru5w0msg0wINGugBe+/NwyAyFZ2KaQ6o4g==", + "dev": true, + "dependencies": { + "@electron-forge/shared-types": "7.6.0", + "fs-extra": "^10.0.0", + "which": "^2.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/maker-deb": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-deb/-/maker-deb-7.6.0.tgz", + "integrity": "sha512-vTg/wJwfdWM4Hm1NlU0g30ODn6z3NBukQdWOS2xXJQ/Y0KnQRVN7ThSlxxzWJy0tI6hGAlpziJjpXozTfhM/Nw==", + "dev": true, + "dependencies": { + "@electron-forge/maker-base": "7.6.0", + "@electron-forge/shared-types": "7.6.0" + }, + "engines": { + "node": ">= 16.4.0" + }, + "optionalDependencies": { + "electron-installer-debian": "^3.2.0" + } + }, + "node_modules/@electron-forge/maker-rpm": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-rpm/-/maker-rpm-7.6.0.tgz", + "integrity": "sha512-pZGpZ+Oum7uyykwi73e+s/LnWDsG+B0t1iU9jCaZObIR0lcISK5VemeIlgm1A4HlDHODdBZ5AEJfIJ5p9t7w/w==", + "dev": true, + "dependencies": { + "@electron-forge/maker-base": "7.6.0", + "@electron-forge/shared-types": "7.6.0" + }, + "engines": { + "node": ">= 16.4.0" + }, + "optionalDependencies": { + "electron-installer-redhat": "^3.2.0" + } + }, + "node_modules/@electron-forge/maker-squirrel": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-squirrel/-/maker-squirrel-7.6.0.tgz", + "integrity": "sha512-8tqsJBRAe37YZSKv1fPc1tijQljkSlUQCaeun37ZOM/viurSeydt5nu2M+UDmJHAfD/PRZMjnYvCCWH+08wGVg==", + "dev": true, + "dependencies": { + "@electron-forge/maker-base": "7.6.0", + "@electron-forge/shared-types": "7.6.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + }, + "optionalDependencies": { + "electron-winstaller": "^5.3.0" + } + }, + "node_modules/@electron-forge/maker-zip": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-zip/-/maker-zip-7.6.0.tgz", + "integrity": "sha512-sDPQoEs6CnkxsydvnfZByBGf+RREky2xqiusWCvaPnUoLRpq96SFaBb1BRCS6tQKQHKkaEUXEC5pBdrYGLHPVg==", + "dev": true, + "dependencies": { + "@electron-forge/maker-base": "7.6.0", + "@electron-forge/shared-types": "7.6.0", + "cross-zip": "^4.0.0", + "fs-extra": "^10.0.0", + "got": "^11.8.5" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/plugin-auto-unpack-natives": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-auto-unpack-natives/-/plugin-auto-unpack-natives-7.6.0.tgz", + "integrity": "sha512-rSWRLJinRIxtlkLke0uJzOLksRnXszu3hZrzlgOWChDuMFM298yb6gxWAjYh94VoNxXrUHl9Cd4ia/5+wgPwwg==", + "dev": true, + "dependencies": { + "@electron-forge/plugin-base": "7.6.0", + "@electron-forge/shared-types": "7.6.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/plugin-base": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.6.0.tgz", + "integrity": "sha512-9llu4algWZJAJFVVZtd/Xa71c0QVxRmoMrpHX2SB+XJ+ZlFVdXrlnhn2hc/CnM0by9cBElyAL3cx3533OKS7lA==", + "dev": true, + "dependencies": { + "@electron-forge/shared-types": "7.6.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/plugin-fuses": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-fuses/-/plugin-fuses-7.6.0.tgz", + "integrity": "sha512-3M6LN0B/y9cZvjpAX7zKVGTNximOEIlYvh2HJJvRARrwOE3eGRBWZsPZg7etqSZtxS6ENtUt/kM88KYOyLfB0w==", + "dev": true, + "dependencies": { + "@electron-forge/plugin-base": "7.6.0", + "@electron-forge/shared-types": "7.6.0" + }, + "engines": { + "node": ">= 16.4.0" + }, + "peerDependencies": { + "@electron/fuses": ">=1.0.0" + } + }, + "node_modules/@electron-forge/plugin-vite": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-vite/-/plugin-vite-7.6.0.tgz", + "integrity": "sha512-9HyiufhVXJ8SWhL9bzRESUa7JpF89EPZ79aYG+/qFmDbO7SxVMxF/z8oSPA1CsuvyfMzNSkPy8oCUoUvbv4Qmg==", + "dev": true, + "dependencies": { + "@electron-forge/core-utils": "7.6.0", + "@electron-forge/plugin-base": "7.6.0", + "@electron-forge/shared-types": "7.6.0", + "@electron-forge/web-multi-logger": "7.6.0", + "chalk": "^4.0.0", + "debug": "^4.3.1", + "fs-extra": "^10.0.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/publisher-base": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/publisher-base/-/publisher-base-7.6.0.tgz", + "integrity": "sha512-IL9bbIb/4J4I1bfW53RAmE/Al835XJsOwFXTLUnxnaGtbWg5jz7eiyw9Vl8XvvfHN1Dpoa9f94to8keU2MXgDg==", + "dev": true, + "dependencies": { + "@electron-forge/shared-types": "7.6.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/shared-types": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.6.0.tgz", + "integrity": "sha512-qpJRaPo/tx/+t3iFdUWnK4Tk/elo+Izk3yS+BhzfaF0XOK8wS+NNYW4vycK6eVMxN3Yu7/924MQFtPlCKlWHvA==", + "dev": true, + "dependencies": { + "@electron-forge/tracer": "7.6.0", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-base": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-base/-/template-base-7.6.0.tgz", + "integrity": "sha512-lhvab8a/knuGnpzep8BMOEkgnkHGr11QELGBzslEnA6rwZi9DDyEgmMCk6VWOVQNHMeuEqh5XlgjVqJmjW6nIQ==", + "dev": true, + "dependencies": { + "@electron-forge/shared-types": "7.6.0", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "fs-extra": "^10.0.0", + "username": "^5.1.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-vite": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite/-/template-vite-7.6.0.tgz", + "integrity": "sha512-C0V0dGDO1hLXnAM9lGnZU0esNOTbxwcgILWJXv0mYErBkmputAIi3HM1Is3h3JdSijXgVbRWcIQxFxJlOCpB/A==", + "dev": true, + "dependencies": { + "@electron-forge/shared-types": "7.6.0", + "@electron-forge/template-base": "7.6.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-vite-typescript": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite-typescript/-/template-vite-typescript-7.6.0.tgz", + "integrity": "sha512-i2Bt5Hehoq2CNNrUQjl8DQX7VatBMQ6mv+CCa+m+EV92nUYxXsoFva62/5ITpc3gFAGd1upw/S7dTbHV6GOwsA==", + "dev": true, + "dependencies": { + "@electron-forge/shared-types": "7.6.0", + "@electron-forge/template-base": "7.6.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-webpack": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack/-/template-webpack-7.6.0.tgz", + "integrity": "sha512-+HEf0ryUfLpHvl27TXSdP2Ob69+ktNtr5EnmroZGGIxhSAtEs4HloPtDF9PSfBzm38pZhQBZn78kY9LbITTGjg==", + "dev": true, + "dependencies": { + "@electron-forge/shared-types": "7.6.0", + "@electron-forge/template-base": "7.6.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-webpack-typescript": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack-typescript/-/template-webpack-typescript-7.6.0.tgz", + "integrity": "sha512-fDj4DkGxJJjGL8lpowFnkX7PvV9koLHKJuyusK8p8ayVMGoHpHrIcVCrV06tKYOvhFrL/ahW+CKKvjlxF8niEg==", + "dev": true, + "dependencies": { + "@electron-forge/shared-types": "7.6.0", + "@electron-forge/template-base": "7.6.0", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/tracer": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.6.0.tgz", + "integrity": "sha512-Rn76RHqNhLyZDnu+xY/X73+bv+Q09XKaZBL/WvlYBbvrrHe26NOHJ3IHXxkWRokSWd4B7lOGLGKm3j1Il8dVbQ==", + "dev": true, + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, + "node_modules/@electron-forge/web-multi-logger": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@electron-forge/web-multi-logger/-/web-multi-logger-7.6.0.tgz", + "integrity": "sha512-Ln4Rn1H/hxs9USvwWmvUYnOIR8kobtglYWJXCERrua8A0zCWsVrs3edO/oKrg68eBd30tiDiJYGLme1ZEXxt+A==", + "dev": true, + "dependencies": { + "express": "^4.17.1", + "express-ws": "^5.0.2", + "xterm": "^4.9.0", + "xterm-addon-fit": "^0.5.0", + "xterm-addon-search": "^0.8.0" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.2.18", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.18.tgz", + "integrity": "sha512-2XyvMe3N3Nrs8cV39IKELRHTYUWFKrmqqSY1U+GMlc0jvqjIVnoxhNd2H4JolWQncbJi1DCvb5TNxZuI2fEjWg==", + "dev": true, + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@electron/node-gyp": { + "version": "10.2.0-electron.1", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "integrity": "sha512-vW3IyJhvR5FY5nhKQcWRDwAlX//GULAJTdJ8quO4oR0qgWBrnLItAQ8o7qbaVHCOscYE45q6yIhMtM45x2zNNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^8.1.0", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.2.1", + "nopt": "^6.0.0", + "proc-log": "^2.0.1", + "semver": "^7.3.5", + "tar": "^6.2.1", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/node-gyp/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/node-gyp/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/node-gyp/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.2.tgz", + "integrity": "sha512-KqVlm9WMWq19lBpCXQoThC/Koaiji2zotUDYwZDaZlZZym+FXY9mQW8wN6sUQ93nkVc42f3TQ1S/XN9S1kjM5Q==", + "dev": true, + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/packager": { + "version": "18.3.6", + "resolved": "https://registry.npmjs.org/@electron/packager/-/packager-18.3.6.tgz", + "integrity": "sha512-1eXHB5t+SQKvUiDpWGpvr90ZSSbXj+isrh3YbjCTjKT4bE4SQrKSBfukEAaBvp67+GXHFtCHjQgN9qSTFIge+Q==", + "dev": true, + "dependencies": { + "@electron/asar": "^3.2.13", + "@electron/get": "^3.0.0", + "@electron/notarize": "^2.1.0", + "@electron/osx-sign": "^1.0.5", + "@electron/universal": "^2.0.1", + "@electron/windows-sign": "^1.0.0", + "debug": "^4.0.1", + "extract-zip": "^2.0.0", + "filenamify": "^4.1.0", + "fs-extra": "^11.1.0", + "galactus": "^1.0.0", + "get-package-info": "^1.0.0", + "junk": "^3.1.0", + "parse-author": "^2.0.0", + "plist": "^3.0.0", + "resedit": "^2.0.0", + "resolve": "^1.1.6", + "semver": "^7.1.3", + "yargs-parser": "^21.1.1" + }, + "bin": { + "electron-packager": "bin/electron-packager.js" + }, + "engines": { + "node": ">= 16.13.0" + }, + "funding": { + "url": "https://github.com/electron/packager?sponsor=1" + } + }, + "node_modules/@electron/packager/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.1.tgz", + "integrity": "sha512-sKGD+xav4Gh25+LcLY0rjIwcCFTw+f/HU1pB48UVbwxXXRGaXEqIH0AaYKN46dgd/7+6kuiDXzoyAEvx1zCsdw==", + "dev": true, + "dependencies": { + "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.0.tgz", + "integrity": "sha512-5zfLHfD6kGgsXzuYlKwlWWO8w6dboKy4dhd7rGnR4rQYumuDgPAF2TYjEa8LUi89KdHxtDy2btq02KvbjhK9Iw==", + "dev": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/colors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", + "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==" + }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.1.tgz", + "integrity": "sha512-DH8vuU7oqHt9RhO3V9Z1b8ek+bOl4+9VLsh0cgL6t7f2WhbuOChm3ft0EmCCsfd4ORi7Cs3II4aNcTXi+bh+wg==", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.2.tgz", + "integrity": "sha512-b1oh54x4DMCdGsB4/7ahiSrViXxaBwRPotiZNnYXjLha9vfuURSAZErki6qjDoSIV0eXx5v57XnTGVtGwnfp2g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collapsible": "1.1.2", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz", + "integrity": "sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", + "integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.1.tgz", + "integrity": "sha512-kNU4FIpcFMBLkOUcgeIteH06/8JLBcYY6Le1iKenDGCYNYFX3TQqCZjzkOsz37h7r94/99GTb7YhEr98ZBJibw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz", + "integrity": "sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.3.tgz", + "integrity": "sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.2.tgz", + "integrity": "sha512-PliMB63vxz7vggcyq0IxNYk8vGDrLXVWw4+W4B8YnwI1s18x7YZYqlG9PLX7XxAJUi0g2DxP4XKJMFHh/iVh9A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", + "integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.4.tgz", + "integrity": "sha512-ap4wdGwK52rJxGkwukU1NrnEodsUFQIooANKu+ey7d6raQ2biTcEf8za1zr0mgFHieevRTB2nK4dJeN8pTAZGQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-menu": "2.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz", + "integrity": "sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz", + "integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.4.tgz", + "integrity": "sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz", + "integrity": "sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.4.tgz", + "integrity": "sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.4.tgz", + "integrity": "sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.3.tgz", + "integrity": "sha512-IQWAsQ7dsLIYDrn0WqPU+cdM7MONTv9nqrLVYoie3BPiabSfUVDe6Fr+oEt0Cofsr9ONDcDe9xhmJbL1Uq1yKg==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz", + "integrity": "sha512-aUACAkXx8LaFymDma+HQVji7WhvEhpFJ7+qPz17Nf4lLZqtreGOFRiNQWQmhzp7kEWg9cOyyQJpdIMUMPc/CPw==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", + "integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.1.tgz", + "integrity": "sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz", + "integrity": "sha512-E0MLLGfOP0l8P/NxgVzfXJ8w3Ch8cdO6UDzJfDChu4EJDy+/WdO5LqpdY8PYnCErkmZH3gZhDL1K7kQ41fAHuQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz", + "integrity": "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz", + "integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.5.tgz", + "integrity": "sha512-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.4", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.4.tgz", + "integrity": "sha512-XDUI0IVYVSwjMXxM6P4Dfti7AH+Y4oS/TB+sglZ/EXc7cqLwGAmp1NlMrcUjj7ks6R5WTZuWKv44FBbLpwU3sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.2.tgz", + "integrity": "sha512-sNlU06ii1/ZcbHf8I9En54ZPW0Vil/yPVg4vQMcFNjrIx51jsHbFl1HYHQvCIWJSr1q0ZmA+iIs/ZTv8h7HHSA==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz", + "integrity": "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz", + "integrity": "sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.1.tgz", + "integrity": "sha512-i77tcgObYr743IonC1hrsnnPmszDRn8p+EGUsUt+5a/JFn28fxaM88Py6V2mc8J5kELMWishI0rLnuGLFD/nnQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.1.tgz", + "integrity": "sha512-OgDLZEA30Ylyz8YSXvnGqIHtERqnUt1KUYTKdw/y8u7Ci6zGiJfXc02jahmcSNK3YcErqioj/9flWC9S1ihfwg==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-toggle": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz", + "integrity": "sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz", + "integrity": "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" + }, + "node_modules/@radix-ui/themes": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/themes/-/themes-3.1.6.tgz", + "integrity": "sha512-4uaUK0E+3ZRURohKNqnzG8LciTJcpppuBbYxkp7miLyPiaXBwKTrEttdQpExsp/fP6J+ss+JHy5FJhU5lboQkg==", + "dependencies": { + "@radix-ui/colors": "^3.0.0", + "@radix-ui/primitive": "^1.1.0", + "@radix-ui/react-accessible-icon": "^1.1.0", + "@radix-ui/react-alert-dialog": "^1.1.2", + "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-compose-refs": "^1.1.0", + "@radix-ui/react-context": "^1.1.1", + "@radix-ui/react-context-menu": "^2.2.2", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-direction": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-hover-card": "^1.1.2", + "@radix-ui/react-navigation-menu": "^1.2.1", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-portal": "^1.1.2", + "@radix-ui/react-primitive": "^2.0.0", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.1", + "@radix-ui/react-roving-focus": "^1.1.0", + "@radix-ui/react-scroll-area": "^1.2.1", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-toggle-group": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "@radix-ui/react-use-callback-ref": "^1.1.0", + "@radix-ui/react-use-controllable-state": "^1.1.0", + "@radix-ui/react-visually-hidden": "^1.1.0", + "classnames": "^2.3.2", + "react-remove-scroll-bar": "^2.3.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@remix-run/router": { + "version": "1.21.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.1.tgz", + "integrity": "sha512-KeBYSwohb8g4/wCcnksvKTYlg69O62sQeLynn2YE+5z7JWEj95if27kclW9QqbrlsQ2DINI8fjbV3zyuKfwjKg==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.30.1.tgz", + "integrity": "sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.30.1.tgz", + "integrity": "sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.30.1.tgz", + "integrity": "sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.30.1.tgz", + "integrity": "sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.30.1.tgz", + "integrity": "sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.30.1.tgz", + "integrity": "sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.30.1.tgz", + "integrity": "sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.30.1.tgz", + "integrity": "sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.30.1.tgz", + "integrity": "sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.30.1.tgz", + "integrity": "sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.30.1.tgz", + "integrity": "sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.30.1.tgz", + "integrity": "sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.30.1.tgz", + "integrity": "sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.1.tgz", + "integrity": "sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz", + "integrity": "sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.1.tgz", + "integrity": "sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.30.1.tgz", + "integrity": "sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.30.1.tgz", + "integrity": "sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.30.1.tgz", + "integrity": "sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "dev": true, + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==" + }, + "node_modules/@types/electron-squirrel-startup": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/electron-squirrel-startup/-/electron-squirrel-startup-1.0.2.tgz", + "integrity": "sha512-AzxnvBzNh8K/0SmxMmZtpJf1/IWoGXLP+pQDuUaVkPyotI8ryvAtBSqgxR/qOSvxWHYWrxkeNsJ+Ca5xOuUxJQ==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", + "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.5.tgz", + "integrity": "sha512-GLZPrd9ckqEBFMcVM/qRFAP0Hg3qiVEojgEFsx/N/zKXsBzbGF6z5FBDpZ0+Xhp1xr+qRZYjfGr1cWHB9oFHSA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, + "node_modules/@types/node": { + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", + "dev": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", + "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", + "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz", + "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "peer": true, + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "peer": true, + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", + "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "peer": true, + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.48", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", + "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "peer": true, + "dependencies": { + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", + "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", + "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", + "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "peer": true, + "dependencies": { + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "vue": "3.5.13" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "peer": true + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-typescript": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz", + "integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==", + "peer": true, + "peerDependencies": { + "acorn": ">=8.9.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ai": { + "version": "3.4.33", + "resolved": "https://registry.npmjs.org/ai/-/ai-3.4.33.tgz", + "integrity": "sha512-plBlrVZKwPoRTmM8+D1sJac9Bq8eaa2jiZlHLZIWekKWI1yMWYZvCCEezY9ASPwRhULYDJB2VhKOBUUeg3S5JQ==", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "@ai-sdk/provider-utils": "1.0.22", + "@ai-sdk/react": "0.0.70", + "@ai-sdk/solid": "0.0.54", + "@ai-sdk/svelte": "0.0.57", + "@ai-sdk/ui-utils": "0.0.50", + "@ai-sdk/vue": "0.0.59", + "@opentelemetry/api": "1.9.0", + "eventsource-parser": "1.1.2", + "json-schema": "^0.4.0", + "jsondiffpatch": "0.6.0", + "secure-json-parse": "^2.7.0", + "zod-to-json-schema": "^3.23.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "openai": "^4.42.0", + "react": "^18 || ^19 || ^19.0.0-rc", + "sswr": "^2.1.0", + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0", + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "openai": { + "optional": true + }, + "react": { + "optional": true + }, + "sswr": { + "optional": true + }, + "svelte": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/ai/node_modules/@ai-sdk/ui-utils": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-0.0.50.tgz", + "integrity": "sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==", + "dependencies": { + "@ai-sdk/provider": "0.0.26", + "@ai-sdk/provider-utils": "1.0.22", + "json-schema": "^0.4.0", + "secure-json-parse": "^2.7.0", + "zod-to-json-schema": "^3.23.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-5.0.0.tgz", + "integrity": "sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==", + "dev": true, + "dependencies": { + "type-fest": "^1.0.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/author-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/author-regex/-/author-regex-1.0.0.tgz", + "integrity": "sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "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.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "optional": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001695", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz", + "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dev": true, + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", + "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "dev": true, + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-zip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cross-zip/-/cross-zip-4.0.1.tgz", + "integrity": "sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=12.10" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "optional": true + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==" + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/electron": { + "version": "33.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.1.0.tgz", + "integrity": "sha512-7KiY6MtRo1fVFLPGyHS7Inh8yZfrbUTy43nNwUgMD2CBk729BgSwOC2WhmcptNJVlzHJpVxSWkiVi2hp9mH/bw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-installer-common": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/electron-installer-common/-/electron-installer-common-0.10.4.tgz", + "integrity": "sha512-8gMNPXfAqUE5CfXg8RL0vXpLE9HAaPkgLXVoHE3BMUzogMWenf4LmwQ27BdCUrEhkjrKl+igs2IHJibclR3z3Q==", + "dev": true, + "optional": true, + "dependencies": { + "@electron/asar": "^3.2.5", + "@malept/cross-spawn-promise": "^1.0.0", + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "glob": "^7.1.4", + "lodash": "^4.17.15", + "parse-author": "^2.0.0", + "semver": "^7.1.1", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "url": "https://github.com/electron-userland/electron-installer-common?sponsor=1" + }, + "optionalDependencies": { + "@types/fs-extra": "^9.0.1" + } + }, + "node_modules/electron-installer-common/node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "optional": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/electron-installer-common/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "optional": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-debian": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/electron-installer-debian/-/electron-installer-debian-3.2.0.tgz", + "integrity": "sha512-58ZrlJ1HQY80VucsEIG9tQ//HrTlG6sfofA3nRGr6TmkX661uJyu4cMPPh6kXW+aHdq/7+q25KyQhDrXvRL7jw==", + "dev": true, + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "@malept/cross-spawn-promise": "^1.0.0", + "debug": "^4.1.1", + "electron-installer-common": "^0.10.2", + "fs-extra": "^9.0.0", + "get-folder-size": "^2.0.1", + "lodash": "^4.17.4", + "word-wrap": "^1.2.3", + "yargs": "^16.0.2" + }, + "bin": { + "electron-installer-debian": "src/cli.js" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-installer-debian/node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "optional": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/electron-installer-debian/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/electron-installer-debian/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "optional": true + }, + "node_modules/electron-installer-debian/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "optional": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-debian/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-installer-debian/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-installer-debian/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/electron-installer-debian/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "optional": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-debian/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-redhat": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/electron-installer-redhat/-/electron-installer-redhat-3.4.0.tgz", + "integrity": "sha512-gEISr3U32Sgtj+fjxUAlSDo3wyGGq6OBx7rF5UdpIgbnpUvMN4W5uYb0ThpnAZ42VEJh/3aODQXHbFS4f5J3Iw==", + "dev": true, + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "@malept/cross-spawn-promise": "^1.0.0", + "debug": "^4.1.1", + "electron-installer-common": "^0.10.2", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "word-wrap": "^1.2.3", + "yargs": "^16.0.2" + }, + "bin": { + "electron-installer-redhat": "src/cli.js" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-installer-redhat/node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "optional": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/electron-installer-redhat/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/electron-installer-redhat/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "optional": true + }, + "node_modules/electron-installer-redhat/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "optional": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-redhat/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-installer-redhat/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-installer-redhat/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/electron-installer-redhat/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "optional": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-installer-redhat/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-log": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.2.4.tgz", + "integrity": "sha512-iX12WXc5XAaKeHg2QpiFjVwL+S1NVHPFd3V5RXtCmKhpAzXsVQnR3UEc0LovM6p6NkUQxDWnkdkaam9FNUVmCA==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/electron-squirrel-startup": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/electron-squirrel-startup/-/electron-squirrel-startup-1.0.1.tgz", + "integrity": "sha512-sTfFIHGku+7PsHLJ7v0dRcZNkALrV+YEozINTW8X1nM//e5O3L+rfYuvSW00lmGHnYmUjARZulD8F2V8ISI9RA==", + "dependencies": { + "debug": "^2.2.0" + } + }, + "node_modules/electron-squirrel-startup/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/electron-squirrel-startup/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.83", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.83.tgz", + "integrity": "sha512-LcUDPqSt+V0QmI47XLzZrz5OqILSMGsPFkDYus22rIbgorSvBYEFqq854ltTmUdHkY92FSdAAvsh4jWEULMdfQ==", + "dev": true + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "optional": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optional": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/electron/node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "20.17.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.14.tgz", + "integrity": "sha512-w6qdYetNL5KRBiSClK/KWai+2IMEJuAj+EujKCumalFOwXtvOXaEan9AuwcRID2IcOIAWSIfR495hBtgKlx2zg==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/electron/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, + "node_modules/electron/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "optional": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz", + "integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "peer": true + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.3.tgz", + "integrity": "sha512-Xddc1RsoFJ4z9nR7W7BFaEPIp4UXoeQ0+077UdWLxbafMQFyU79sQJMk7kxNgRwQ9/aVgaKacCHC2pUACGwmYw==", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "peer": true + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true + }, + "node_modules/eventsource-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", + "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", + "dev": true, + "dependencies": { + "homedir-polyfill": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "dev": true + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-ws": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz", + "integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==", + "dev": true, + "dependencies": { + "ws": "^7.4.6" + }, + "engines": { + "node": ">=4.5.0" + }, + "peerDependencies": { + "express": "^4.0.0 || ^5.0.0-alpha.1" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", + "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/filenamify": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", + "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", + "dev": true, + "dependencies": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "dev": true + }, + "node_modules/flora-colossus": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flora-colossus/-/flora-colossus-2.0.0.tgz", + "integrity": "sha512-dz4HxH6pOvbUzZpZ/yXhafjbR2I8cenK5xL0KtBFb7U2ADsR+OwXifnxZjij/pZWF775uSCMzWVd+jDik2H2IA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "fs-extra": "^10.1.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.1.tgz", + "integrity": "sha512-EQa8c9lWVOm4zlz14MsBJWr8woq87HsNmsBnQNvcS0hs8uzw6HtGAxZyIU7EGTVpHD1C1n01ufxRyarXcNzpPg==", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/galactus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/galactus/-/galactus-1.0.0.tgz", + "integrity": "sha512-R1fam6D4CyKQGNlvJne4dkNF+PvUUl7TAJInvTGa9fti9qAv95quQz29GXapA4d8Ec266mJJxFVh82M4GIIGDQ==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "flora-colossus": "^2.0.0", + "fs-extra": "^10.1.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/gar": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/gar/-/gar-1.0.4.tgz", + "integrity": "sha512-w4n9cPWyP7aHxKxYHFQMegj7WIAsL/YX/C4Bs5Rr8s1H9M1rNtRWRsw+ovYMkXDQ5S4ZbYHsHAPmevPjPgw44w==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "optional": true + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-folder-size": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/get-folder-size/-/get-folder-size-2.0.1.tgz", + "integrity": "sha512-+CEb+GDCM7tkOS2wdMKTn9vU7DgnKUTuDlehkNJKNSovdCOVxs14OfKCk4cvSaR3za4gj+OBdl9opPN9xrJ0zA==", + "dev": true, + "optional": true, + "dependencies": { + "gar": "^1.0.4", + "tiny-each-async": "2.0.3" + }, + "bin": { + "get-folder-size": "bin/get-folder-size" + } + }, + "node_modules/get-installed-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/get-installed-path/-/get-installed-path-2.1.1.tgz", + "integrity": "sha512-Qkn9eq6tW5/q9BDVdMpB8tOHljX9OSP0jRC5TRNVA4qRc839t4g8KQaR8t0Uv0EFVL0MlyG7m/ofjEgAROtYsA==", + "dev": true, + "dependencies": { + "global-modules": "1.0.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-package-info": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-package-info/-/get-package-info-1.0.0.tgz", + "integrity": "sha512-SCbprXGAPdIhKAXiG+Mk6yeoFH61JlYunqdFQFHDtLjJlDjFf6x07dsS8acO+xWt52jpdVo49AlVDnUVK1sDNw==", + "dev": true, + "dependencies": { + "bluebird": "^3.1.1", + "debug": "^2.2.0", + "lodash.get": "^4.0.0", + "read-pkg-up": "^2.0.0" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/get-package-info/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/get-package-info/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "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" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "dependencies": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "dev": true, + "dependencies": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", + "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz", + "integrity": "sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", + "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/hastscript/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, + "node_modules/hastscript/node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hastscript/node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hastscript/node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==" + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "dependencies": { + "parse-passwd": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-async-function": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.0.tgz", + "integrity": "sha512-GExz9MtyhlZyXYLxzlJRj5WUCE661zhDa1Yna52CN57AJsymh+DvXXjyveSioqSRdxvUrdKdvqB1b5cVKsNpWQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.1.tgz", + "integrity": "sha512-l9qO6eFlUETHtuihLcYOaLKByJ1f+N4kthcU9YjHy3N+B3hWv0y/2Nd0mu/7lTFnRQHTrSdXF50HQ3bl5fEnng==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.0.tgz", + "integrity": "sha512-SXM8Nwyys6nT5WP6pltOwKytLV7FqQ4UiibxVmW+EIosHcmCqkkjViTb5SNssDlkCiEYRP1/pdWUKVvZBmsR2Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsondiffpatch": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", + "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", + "dependencies": { + "@types/diff-match-patch": "^1.0.36", + "chalk": "^5.3.0", + "diff-match-patch": "^1.0.5" + }, + "bin": { + "jsondiffpatch": "bin/jsondiffpatch.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/jsondiffpatch/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/junk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", + "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/lint-staged": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.4.1.tgz", + "integrity": "sha512-P8yJuVRyLrm5KxCtFx+gjI5Bil+wO7wnTl7C3bXhvtTaAFGirzeB24++D0wGoUwxrUKecNiehemgCob9YL39NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "~5.4.1", + "commander": "~12.1.0", + "debug": "~4.4.0", + "execa": "~8.0.1", + "lilconfig": "~3.1.3", + "listr2": "~8.2.5", + "micromatch": "~4.0.8", + "pidtree": "~0.6.0", + "string-argv": "~0.3.2", + "yaml": "~2.6.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/lint-staged/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/listr2": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", + "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/lint-staged/node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lint-staged/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/yaml": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/listr2": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-7.0.2.tgz", + "integrity": "sha512-rJysbR9GKIalhTbVL2tYbF2hVyDnrf7pFUZBwjPaMIdadYHmeT+EVi/Bu3qd7ETQPahTotg2WRCatXwRBW554g==", + "dev": true, + "dependencies": { + "cli-truncate": "^3.1.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^5.0.1", + "rfdc": "^1.3.0", + "wrap-ansi": "^8.1.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha512-3p6ZOGNbiX4CdvEd1VcE6yi78UrGNpjHO33noGwHCnT/o2fyllJDepsm8+mFFv/DvtwFHht5HIHSyOy5a+ChVQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "peer": true + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-5.0.1.tgz", + "integrity": "sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==", + "dev": true, + "dependencies": { + "ansi-escapes": "^5.0.0", + "cli-cursor": "^4.0.0", + "slice-ansi": "^5.0.0", + "strip-ansi": "^7.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.454.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz", + "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dev": true, + "dependencies": { + "p-defer": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "dependencies": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.1.tgz", + "integrity": "sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz", + "integrity": "sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.3.tgz", + "integrity": "sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", + "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/node-abi": { + "version": "3.73.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.73.0.tgz", + "integrity": "sha512-z8iYzQGBu35ZkTQ9mtR8RqugJZ9RCLn8fv3d7LsgDBzOijGQP3RdKTX4LA7LXw03ZhU5z0l4xfhIMgSES31+cg==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-api-version": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.0.tgz", + "integrity": "sha512-fthTTsi8CxaBXMaBAD7ST2uylwvsnYxh2PfaScwpMhos6KlSFajXQPcM4ogNE1q2s3Lbz9GCGqeIHC+C6OZnKg==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-author": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-author/-/parse-author-2.0.0.tgz", + "integrity": "sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==", + "dev": true, + "dependencies": { + "author-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, + "node_modules/parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==", + "dev": true, + "dependencies": { + "error-ex": "^1.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/pe-library": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", + "integrity": "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==", + "dev": true, + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", + "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prismjs": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", + "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/proc-log": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", + "integrity": "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-icons": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", + "integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-markdown": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.3.tgz", + "integrity": "sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw==", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz", + "integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.28.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.2.tgz", + "integrity": "sha512-BgFY7+wEGVjHCiqaj2XiUBQ1kkzfg6UoKYwEe0wv+FF+HNPCxtS/MVPvLAPH++EsuCMReZl9RYVGqcHLk5ms3A==", + "dependencies": { + "@remix-run/router": "1.21.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.28.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.2.tgz", + "integrity": "sha512-O81EWqNJWqvlN/a7eTudAdQm0TbI7hw+WIi7OwwMcTn5JMyZ0ibTFNGz+t+Lju0df4LcqowCegcrK22lB1q9Kw==", + "dependencies": { + "@remix-run/router": "1.21.1", + "react-router": "6.28.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-select": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz", + "integrity": "sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-syntax-highlighter": { + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.1.tgz", + "integrity": "sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg==", + "dependencies": { + "@babel/runtime": "^7.3.1", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.27.0", + "refractor": "^3.6.0" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/react-toastify": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-8.0.0.tgz", + "integrity": "sha512-7a5uhwbJ5Ivp5QyJN8P9M8g+7wksJt51QuYAZW0c3pDOh0Jx8lH7XzNHzzJg4NHup9n4zcqH9rwXknnyzYg2OA==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-toastify/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha512-eFIBOPW7FGjzBuk3hdXEuNSiTZS/xEMlH49HxMyzb0hyPfu4EhVjT2DH32K1hSSmVq4sebAWnZuuY5auISUTGA==", + "dev": true, + "dependencies": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha512-1orxQfbWGUiTn9XsPlChs6rLie/AV9jwZTGmu2NZw/CUDJQchXJFYE0Fq5j7+n558T1JhDWLdhyd1Zj+wLY//w==", + "dev": true, + "dependencies": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha512-dUnb5dXUf+kzhC/W/F4e5/SkluXIFf5VUHolW1Eg1irn1hGWjPGdsRcvYJ1nD6lhk8Ir7VM0bHJKsYTx8Jx9OQ==", + "dev": true, + "dependencies": { + "pify": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/refractor": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", + "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", + "dependencies": { + "hastscript": "^6.0.0", + "parse-entities": "^2.0.0", + "prismjs": "~1.27.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/prismjs": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", + "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.1.tgz", + "integrity": "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-2.0.3.tgz", + "integrity": "sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==", + "dev": true, + "dependencies": { + "pe-library": "^1.0.1" + }, + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, + "node_modules/resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", + "dev": true, + "dependencies": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-package": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-package/-/resolve-package-1.0.1.tgz", + "integrity": "sha512-rzB7NnQpOkPHBWFPP3prUMqOP6yg3HkRGgcvR+lDyvyHoY3fZLFLYDkPXh78SPVBAE6VTCk/V+j8we4djg6o4g==", + "dev": true, + "dependencies": { + "get-installed-path": "^2.0.3" + }, + "engines": { + "node": ">=4", + "npm": ">=2" + } + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.30.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz", + "integrity": "sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.30.1", + "@rollup/rollup-android-arm64": "4.30.1", + "@rollup/rollup-darwin-arm64": "4.30.1", + "@rollup/rollup-darwin-x64": "4.30.1", + "@rollup/rollup-freebsd-arm64": "4.30.1", + "@rollup/rollup-freebsd-x64": "4.30.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.30.1", + "@rollup/rollup-linux-arm-musleabihf": "4.30.1", + "@rollup/rollup-linux-arm64-gnu": "4.30.1", + "@rollup/rollup-linux-arm64-musl": "4.30.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.30.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.30.1", + "@rollup/rollup-linux-riscv64-gnu": "4.30.1", + "@rollup/rollup-linux-s390x-gnu": "4.30.1", + "@rollup/rollup-linux-x64-gnu": "4.30.1", + "@rollup/rollup-linux-x64-musl": "4.30.1", + "@rollup/rollup-win32-arm64-msvc": "4.30.1", + "@rollup/rollup-win32-ia32-msvc": "4.30.1", + "@rollup/rollup-win32-x64-msvc": "4.30.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "optional": true + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "dev": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/sswr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sswr/-/sswr-2.1.0.tgz", + "integrity": "sha512-Cqc355SYlTAaUt8iDPaC/4DPPXK925PePLMxyBKuWd5kKc5mwsG3nT9+Mq2tyguL5s7b4Jg+IRMpTRsNTAfpSQ==", + "dependencies": { + "swrev": "^4.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-outer/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "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" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sudo-prompt": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", + "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.0.tgz", + "integrity": "sha512-qvd2GvvYnJxS/MteQKFSMyq8cQrAAut28QZ39ySv9k3ggmhw4Au4Rfcsqva74i0xMys//OhbhVCNfXPrDzL/Bg==", + "peer": true, + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "acorn-typescript": "^1.4.13", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "esm-env": "^1.2.1", + "esrap": "^1.4.3", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/swr": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.0.tgz", + "integrity": "sha512-NyZ76wA4yElZWBHzSgEJc28a0u6QZvhb6w0azeL2k7+Q1gAzVK+IqQYXhVOC/mzi+HZIozrZvBVeSeOZNR2bqA==", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/swrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/swrev/-/swrev-4.0.0.tgz", + "integrity": "sha512-LqVcOHSB4cPGgitD1riJ1Hh4vdmITOp+BkmfmXRh4hSF/t7EnS4iD+SOTmq7w5pPm/SiPeto4ADbKS6dHUDWFA==" + }, + "node_modules/swrv": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/swrv/-/swrv-1.0.4.tgz", + "integrity": "sha512-zjEkcP8Ywmj+xOJW3lIT65ciY/4AL4e/Or7Gj0MzU3zBJNMdJiT8geVZhINavnlHRMMCcJLHhraLTAiDOTmQ9g==", + "peerDependencies": { + "vue": ">=3.2.26 < 4" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "optional": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "optional": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tiny-each-async": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz", + "integrity": "sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==", + "dev": true, + "optional": true + }, + "node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "optional": true, + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/trim-repeated/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", + "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/username": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/username/-/username-5.1.0.tgz", + "integrity": "sha512-PCKbdWw85JsYMvmCv5GH3kXmM66rCd9m1hBEDutPNv94b/pqCMT4NtcKyeWYvLFiE8b+ha1Jdl8XAaUdPn5QTg==", + "dev": true, + "dependencies": { + "execa": "^1.0.0", + "mem": "^4.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", + "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/xterm": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.19.0.tgz", + "integrity": "sha512-c3Cp4eOVsYY5Q839dR5IejghRPpxciGmLWWaP9g+ppfMeBChMeLa1DCA+pmX/jyDZ+zxFOmlJL/82qVdayVoGQ==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "dev": true + }, + "node_modules/xterm-addon-fit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz", + "integrity": "sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.", + "dev": true, + "peerDependencies": { + "xterm": "^4.0.0" + } + }, + "node_modules/xterm-addon-search": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/xterm-addon-search/-/xterm-addon-search-0.8.2.tgz", + "integrity": "sha512-I1863mjn8P6uVrqm/X+btalVsqjAKLhnhpbP7SavAOpEkI1jJhbHU2UTp7NjeRtcKTks6UWk/ycgds5snDSejg==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-search instead.", + "dev": true, + "peerDependencies": { + "xterm": "^4.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yarn-or-npm": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/yarn-or-npm/-/yarn-or-npm-3.0.1.tgz", + "integrity": "sha512-fTiQP6WbDAh5QZAVdbMQkecZoahnbOjClTQhzv74WX5h2Uaidj1isf9FDes11TKtsZ0/ZVfZsqZ+O3x6aLERHQ==", + "dev": true, + "dependencies": { + "cross-spawn": "^6.0.5", + "pkg-dir": "^4.2.0" + }, + "bin": { + "yarn-or-npm": "bin/index.js", + "yon": "bin/index.js" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/yarn-or-npm/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/yarn-or-npm/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/yarn-or-npm/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/yarn-or-npm/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yarn-or-npm/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yarn-or-npm/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", + "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", + "peer": true + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.1.tgz", + "integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==", + "peerDependencies": { + "zod": "^3.24.1" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ui/desktop/package.json b/ui/desktop/package.json new file mode 100644 index 00000000..e8c13fb1 --- /dev/null +++ b/ui/desktop/package.json @@ -0,0 +1,100 @@ +{ + "name": "goose-app", + "productName": "Goose", + "version": "1.0.0", + "description": "Goose App", + "main": ".vite/build/main.js", + "scripts": { + "start-gui": "electron-forge start", + "start": "cd ../.. && just run-ui", + "start:test-error": "GOOSE_TEST_ERROR=true electron-forge start", + "package": "electron-forge package", + "make": "electron-forge make", + "bundle:default": "npm run make && cd out/Goose-darwin-arm64 && ditto -c -k --sequesterRsrc --keepParent Goose.app Goose.zip", + "debug": "echo 'run --remote-debugging-port=8315' && lldb out/Goose-darwin-arm64/Goose.app", + "test-e2e": "electron-forge start > /tmp/out.txt & ELECTRON_PID=$! && sleep 12 && if grep -q 'renderer: ChatWindow loaded' /tmp/out.txt; then echo 'process is running'; pkill -f electron; else echo 'not starting correctly'; cat /tmp/out.txt; pkill -f electron; exit 1; fi", + "lint": "eslint \"src/**/*.{ts,tsx}\" --fix", + "lint:check": "eslint \"src/**/*.{ts,tsx}\"", + "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"", + "prepare": "cd ../.. && husky install" + }, + "devDependencies": { + "@electron-forge/cli": "^7.5.0", + "@electron-forge/maker-deb": "^7.5.0", + "@electron-forge/maker-rpm": "^7.5.0", + "@electron-forge/maker-squirrel": "^7.5.0", + "@electron-forge/maker-zip": "^7.5.0", + "@electron-forge/plugin-auto-unpack-natives": "^7.5.0", + "@electron-forge/plugin-fuses": "^7.5.0", + "@electron-forge/plugin-vite": "^7.5.0", + "@electron/fuses": "^1.8.0", + "@eslint/js": "^8.56.0", + "@tailwindcss/typography": "^0.5.15", + "@types/cors": "^2.8.17", + "@types/electron-squirrel-startup": "^1.0.2", + "@types/express": "^5.0.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "electron": "33.1.0", + "eslint": "^8.56.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "husky": "^8.0.0", + "lint-staged": "^15.4.1", + "postcss": "^8.4.47", + "prettier": "^3.4.2", + "tailwindcss": "^3.4.14", + "vite": "^5.0.12" + }, + "keywords": [], + "license": "Apache-2.0", + "lint-staged": { + "src/**/*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "src/**/*.{css,json}": [ + "prettier --write" + ] + }, + "dependencies": { + "@ai-sdk/openai": "^0.0.72", + "@ai-sdk/ui-utils": "^1.0.2", + "@radix-ui/react-accordion": "^1.2.2", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-icons": "^1.3.1", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.5", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/themes": "^3.1.5", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/react-syntax-highlighter": "^15.5.13", + "ai": "^3.4.33", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "electron-log": "^5.2.2", + "electron-squirrel-startup": "^1.0.1", + "express": "^4.21.1", + "framer-motion": "^11.11.11", + "lucide-react": "^0.454.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-icons": "^5.3.0", + "react-markdown": "^9.0.1", + "react-router-dom": "^6.28.0", + "react-select": "^5.9.0", + "react-syntax-highlighter": "^15.6.1", + "react-toastify": "^8.0.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "unist-util-visit": "^5.0.0" + } +} diff --git a/ui/desktop/postcss.config.js b/ui/desktop/postcss.config.js new file mode 100644 index 00000000..33ad091d --- /dev/null +++ b/ui/desktop/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx new file mode 100644 index 00000000..7619dc66 --- /dev/null +++ b/ui/desktop/src/App.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import { addExtensionFromDeepLink } from './extensions'; +import { useNavigate } from 'react-router-dom'; +import LauncherWindow from './LauncherWindow'; +import ChatWindow from './ChatWindow'; +import ErrorScreen from './components/ErrorScreen'; +import 'react-toastify/dist/ReactToastify.css'; +import { ToastContainer } from 'react-toastify'; +import { ModelProvider } from './components/settings/models/ModelContext'; +import { ActiveKeysProvider } from './components/settings/api_keys/ActiveKeysContext'; + +export default function App() { + const [fatalError, setFatalError] = useState(null); + const searchParams = new URLSearchParams(window.location.search); + const isLauncher = searchParams.get('window') === 'launcher'; + const navigate = useNavigate(); + + useEffect(() => { + window.electron.on('add-extension', (_, link) => { + window.electron.logInfo(`Adding extension from deep link ${link}`); + addExtensionFromDeepLink(link, navigate); + }); + }, [navigate]); + + useEffect(() => { + const handleFatalError = (_: any, errorMessage: string) => { + setFatalError(errorMessage); + }; + + // Listen for fatal errors from main process + window.electron.on('fatal-error', handleFatalError); + + return () => { + window.electron.off('fatal-error', handleFatalError); + }; + }, []); + + if (fatalError) { + return window.electron.reloadApp()} />; + } + + return ( + + + {isLauncher ? : } + + + + ); +} diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx new file mode 100644 index 00000000..d4ac0013 --- /dev/null +++ b/ui/desktop/src/ChatWindow.tsx @@ -0,0 +1,458 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Message, useChat } from './ai-sdk-fork/useChat'; +import { getApiUrl, getSecretKey } from './config'; +import BottomMenu from './components/BottomMenu'; +import FlappyGoose from './components/FlappyGoose'; +import GooseMessage from './components/GooseMessage'; +import Input from './components/Input'; +import LoadingGoose from './components/LoadingGoose'; +import MoreMenu from './components/MoreMenu'; +import { Card } from './components/ui/card'; +import { ScrollArea } from './components/ui/scroll-area'; +import UserMessage from './components/UserMessage'; +import WingToWing, { Working } from './components/WingToWing'; +import { askAi } from './utils/askAI'; +import { getStoredModel, Provider } from './utils/providerUtils'; +import { ChatLayout } from './components/chat_window/ChatLayout'; +import { ChatRoutes } from './components/chat_window/ChatRoutes'; +import { WelcomeScreen } from './components/welcome_screen/WelcomeScreen'; +import { getStoredProvider, initializeSystem } from './utils/providerUtils'; +import { useModel } from './components/settings/models/ModelContext'; +import { useRecentModels } from './components/settings/models/RecentModels'; +import { createSelectedModel } from './components/settings/models/utils'; +import { getDefaultModel } from './components/settings/models/hardcoded_stuff'; +import Splash from './components/Splash'; +import { loadAndAddStoredExtensions } from './extensions'; + +declare global { + interface Window { + electron: { + stopPowerSaveBlocker: () => void; + startPowerSaveBlocker: () => void; + hideWindow: () => void; + createChatWindow: () => void; + getConfig: () => { GOOSE_PROVIDER: string }; + logInfo: (message: string) => void; + showNotification: (opts: { title: string; body: string }) => void; + getBinaryPath: (binary: string) => Promise; + app: any; + }; + appConfig: { + get: (key: string) => any; + }; + } +} + +export interface Chat { + id: number; + title: string; + messages: Array<{ + id: string; + role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool'; + content: string; + }>; +} + +type ScrollBehavior = 'auto' | 'smooth' | 'instant'; + +export function ChatContent({ + chats, + setChats, + selectedChatId, + setSelectedChatId, + initialQuery, + setProgressMessage, + setWorking, +}: { + chats: Chat[]; + setChats: React.Dispatch>; + selectedChatId: number; + setSelectedChatId: React.Dispatch>; + initialQuery: string | null; + setProgressMessage: React.Dispatch>; + setWorking: React.Dispatch>; +}) { + const chat = chats.find((c: Chat) => c.id === selectedChatId); + const [messageMetadata, setMessageMetadata] = useState>({}); + const [hasMessages, setHasMessages] = useState(false); + const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); + const [showGame, setShowGame] = useState(false); + const messagesEndRef = useRef(null); + const [working, setWorkingLocal] = useState(Working.Idle); + + useEffect(() => { + setWorking(working); + }, [working, setWorking]); + + const updateWorking = (newWorking: Working) => { + setWorkingLocal(newWorking); + }; + + const { messages, append, stop, isLoading, error, setMessages } = useChat({ + api: getApiUrl('/reply'), + initialMessages: chat?.messages || [], + onToolCall: ({ toolCall }) => { + updateWorking(Working.Working); + setProgressMessage(`Executing tool: ${toolCall.toolName}`); + requestAnimationFrame(() => scrollToBottom('instant')); + }, + onResponse: (response) => { + if (!response.ok) { + setProgressMessage('An error occurred while receiving the response.'); + updateWorking(Working.Idle); + } else { + setProgressMessage('thinking...'); + updateWorking(Working.Working); + } + }, + onFinish: async (message, _) => { + window.electron.stopPowerSaveBlocker(); + setTimeout(() => { + setProgressMessage('Task finished. Click here to expand.'); + updateWorking(Working.Idle); + }, 500); + + const fetchResponses = await askAi(message.content); + setMessageMetadata((prev) => ({ ...prev, [message.id]: fetchResponses })); + + requestAnimationFrame(() => scrollToBottom('smooth')); + + const timeSinceLastInteraction = Date.now() - lastInteractionTime; + window.electron.logInfo('last interaction:' + lastInteractionTime); + if (timeSinceLastInteraction > 60000) { + // 60000ms = 1 minute + + window.electron.showNotification({ + title: 'Goose finished the task.', + body: 'Click here to expand.', + }); + } + }, + }); + + // Update chat messages when they change + useEffect(() => { + const updatedChats = chats.map((c) => (c.id === selectedChatId ? { ...c, messages } : c)); + setChats(updatedChats); + }, [messages, selectedChatId]); + + const initialQueryAppended = useRef(false); + useEffect(() => { + if (initialQuery && !initialQueryAppended.current) { + append({ role: 'user', content: initialQuery }); + initialQueryAppended.current = true; + } + }, [initialQuery]); + + useEffect(() => { + if (messages.length > 0) { + setHasMessages(true); + } + }, [messages]); + + const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ + behavior, + block: 'end', + inline: 'nearest', + }); + } + }; + + // Single effect to handle all scrolling + useEffect(() => { + if (isLoading || messages.length > 0 || working === Working.Working) { + scrollToBottom(isLoading || working === Working.Working ? 'instant' : 'smooth'); + } + }, [messages, isLoading, working]); + + // Handle submit + const handleSubmit = (e: React.FormEvent) => { + window.electron.startPowerSaveBlocker(); + const customEvent = e as CustomEvent; + const content = customEvent.detail?.value || ''; + if (content.trim()) { + setLastInteractionTime(Date.now()); + append({ + role: 'user', + content: content, + }); + scrollToBottom('instant'); + } + }; + + if (error) { + console.log('Error:', error); + } + + const onStopGoose = () => { + stop(); + setLastInteractionTime(Date.now()); + window.electron.stopPowerSaveBlocker(); + + const lastMessage: Message = messages[messages.length - 1]; + if (lastMessage.role === 'user' && lastMessage.toolInvocations === undefined) { + // Remove the last user message. + if (messages.length > 1) { + setMessages(messages.slice(0, -1)); + } else { + setMessages([]); + } + } else if (lastMessage.role === 'assistant' && lastMessage.toolInvocations !== undefined) { + // Add messaging about interrupted ongoing tool invocations + const newLastMessage: Message = { + ...lastMessage, + toolInvocations: lastMessage.toolInvocations.map((invocation) => { + if (invocation.state !== 'result') { + return { + ...invocation, + result: [ + { + audience: ['user'], + text: 'Interrupted.\n', + type: 'text', + }, + { + audience: ['assistant'], + text: 'Interrupted by the user to make a correction.\n', + type: 'text', + }, + ], + state: 'result', + }; + } else { + return invocation; + } + }), + }; + + const updatedMessages = [...messages.slice(0, -1), newLastMessage]; + setMessages(updatedMessages); + } + }; + + return ( +
+
+ +
+ + {messages.length === 0 ? ( + + ) : ( + + {messages.map((message) => ( +
+ {message.role === 'user' ? ( + + ) : ( + + )} +
+ ))} + {/* {isLoading && ( +
+
setShowGame(true)} style={{ cursor: 'pointer' }}> +
+
+ )} */} + {error && ( +
+
+ {error.message || 'Honk! Goose experienced an error while responding'} + {error.status && (Status: {error.status})} +
+
{ + const lastUserMessage = messages.reduceRight( + (found, m) => found || (m.role === 'user' ? m : null), + null + ); + if (lastUserMessage) { + append({ + role: 'user', + content: lastUserMessage.content, + }); + } + }} + > + Retry Last Message +
+
+ )} +
+
+ + )} + +
+ {isLoading && } + + +
+ + + {showGame && setShowGame(false)} />} +
+ ); +} + +export default function ChatWindow() { + // Shared function to create a chat window + const openNewChatWindow = () => { + window.electron.createChatWindow(); + }; + const { switchModel, currentModel } = useModel(); // Access switchModel via useModel + const { addRecentModel } = useRecentModels(); // Access addRecentModel from useRecentModels + + // Add keyboard shortcut handler + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Check for Command+N (Mac) or Control+N (Windows/Linux) + if ((event.metaKey || event.ctrlKey) && event.key === 'n') { + event.preventDefault(); // Prevent default browser behavior + openNewChatWindow(); + } + }; + + // Add event listener + window.addEventListener('keydown', handleKeyDown); + + // Cleanup + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + // Get initial query and history from URL parameters + const searchParams = new URLSearchParams(window.location.search); + const initialQuery = searchParams.get('initialQuery'); + const historyParam = searchParams.get('history'); + const initialHistory = historyParam ? JSON.parse(decodeURIComponent(historyParam)) : []; + + const [chats, setChats] = useState(() => { + const firstChat = { + id: 1, + title: initialQuery || 'Chat 1', + messages: initialHistory.length > 0 ? initialHistory : [], + }; + return [firstChat]; + }); + + const [selectedChatId, setSelectedChatId] = useState(1); + const [mode, setMode] = useState<'expanded' | 'compact'>(initialQuery ? 'compact' : 'expanded'); + const [working, setWorking] = useState(Working.Idle); + const [progressMessage, setProgressMessage] = useState(''); + const [selectedProvider, setSelectedProvider] = useState(null); + const [showWelcomeModal, setShowWelcomeModal] = useState(true); + + // Add this useEffect to track changes and update welcome state + const toggleMode = () => { + const newMode = mode === 'expanded' ? 'compact' : 'expanded'; + console.log(`Toggle to ${newMode}`); + setMode(newMode); + }; + + window.electron.logInfo('ChatWindow loaded'); + + // Fix the handleSubmit function syntax + const handleSubmit = () => { + setShowWelcomeModal(false); + }; + + useEffect(() => { + // Check if we already have a provider set + const config = window.electron.getConfig(); + const storedProvider = getStoredProvider(config); + + if (storedProvider) { + setShowWelcomeModal(false); + } else { + setShowWelcomeModal(true); + } + }, []); + + const storeSecret = async (key: string, value: string) => { + const response = await fetch(getApiUrl('/secrets/store'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + body: JSON.stringify({ key, value }), + }); + + if (!response.ok) { + throw new Error(`Failed to store secret: ${response.statusText}`); + } + + return response; + }; + + // Initialize system on load if we have a stored provider + useEffect(() => { + const setupStoredProvider = async () => { + const config = window.electron.getConfig(); + const storedProvider = getStoredProvider(config); + const storedModel = getStoredModel(); + if (storedProvider) { + try { + await initializeSystem(storedProvider, storedModel); + + if (!storedModel) { + // get the default model + const modelName = getDefaultModel(storedProvider.toLowerCase()); + + // create model object + const model = createSelectedModel(storedProvider.toLowerCase(), modelName); + + // Call the context's switchModel to track the set model state in the front end + switchModel(model); + + // Keep track of the recently used models + addRecentModel(model); + + console.log('set up provider with default model', storedProvider, modelName); + } + } catch (error) { + console.error('Failed to initialize with stored provider:', error); + } + } + }; + + setupStoredProvider(); + }, []); + + // Render WelcomeScreen at root level if showing + if (showWelcomeModal) { + return ; + } + + // Only render ChatLayout if not showing welcome screen + return ( +
+ + + +
+ ); +} diff --git a/ui/desktop/src/LauncherWindow.tsx b/ui/desktop/src/LauncherWindow.tsx new file mode 100644 index 00000000..a4723a65 --- /dev/null +++ b/ui/desktop/src/LauncherWindow.tsx @@ -0,0 +1,48 @@ +import React, { useRef, useState } from 'react'; + +declare global { + interface Window { + electron: { + logInfo(msg: string): unknown; + on(channel: string, arg1: (event: any, message: any) => void): unknown; + stopPowerSaveBlocker(): unknown; + startPowerSaveBlocker(): unknown; + hideWindow: () => void; + createChatWindow: (query: string) => void; + }; + } +} + +export default function SpotlightWindow() { + const [query, setQuery] = useState(''); + const inputRef = useRef(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (query.trim()) { + // Create a new chat window with the query + window.electron.createChatWindow(query); + setQuery(''); + inputRef.current.blur(); + } + }; + + return ( +
+
+ setQuery(e.target.value)} + className="w-full bg-transparent text-black text-xl px-4 py-2 outline-none placeholder-gray-400" + placeholder="Type a command..." + autoFocus + /> +
+
+ ); +} diff --git a/ui/desktop/src/ai-sdk-fork/README.md b/ui/desktop/src/ai-sdk-fork/README.md new file mode 100644 index 00000000..5da9ff80 --- /dev/null +++ b/ui/desktop/src/ai-sdk-fork/README.md @@ -0,0 +1,5 @@ +# AI + +This is a small fork of some files in the Vercel AI SDK to make a custom version of useChat which doesn't append text content to messages. + +We can work to surface our desired functionality and upstream a change that could support it. diff --git a/ui/desktop/src/ai-sdk-fork/call-custom-chat-api.ts b/ui/desktop/src/ai-sdk-fork/call-custom-chat-api.ts new file mode 100644 index 00000000..3d723011 --- /dev/null +++ b/ui/desktop/src/ai-sdk-fork/call-custom-chat-api.ts @@ -0,0 +1,92 @@ +import { processCustomChatResponse } from './process-custom-chat-response'; +import { IdGenerator, JSONValue, Message, UseChatOptions } from '@ai-sdk/ui-utils'; + +// use function to allow for mocking in tests: +const getOriginalFetch = () => fetch; + +export async function callCustomChatApi({ + api, + body, + streamProtocol = 'data', + credentials, + headers, + abortController, + restoreMessagesOnFailure, + onResponse, + onUpdate, + onFinish, + onToolCall, + generateId, + fetch = getOriginalFetch(), +}: { + api: string; + body: Record; + streamProtocol: 'data' | 'text' | undefined; + credentials: RequestCredentials | undefined; + headers: HeadersInit | undefined; + abortController: (() => AbortController | null) | undefined; + restoreMessagesOnFailure: () => void; + onResponse: ((response: Response) => void | Promise) | undefined; + onUpdate: (newMessages: Message[], data: JSONValue[] | undefined) => void; + onFinish: UseChatOptions['onFinish']; + onToolCall: UseChatOptions['onToolCall']; + generateId: IdGenerator; + fetch: ReturnType | undefined; +}) { + const response = await fetch(api, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + signal: abortController?.()?.signal, + credentials, + }).catch((err) => { + restoreMessagesOnFailure(); + throw err; + }); + + if (onResponse) { + try { + await onResponse(response); + } catch (err) { + throw err; + } + } + + if (!response.ok) { + restoreMessagesOnFailure(); + throw new Error((await response.text()) ?? 'Failed to fetch the chat response.'); + } + + if (!response.body) { + throw new Error('The response body is empty.'); + } + + switch (streamProtocol) { + case 'text': { + throw new Error('Text protocol not supported in custom chat API'); + } + + case 'data': { + await processCustomChatResponse({ + stream: response.body, + update: onUpdate, + onToolCall, + onFinish({ message, finishReason, usage }) { + if (onFinish && message != null) { + onFinish(message, { usage, finishReason }); + } + }, + generateId, + }); + return; + } + + default: { + const exhaustiveCheck: never = streamProtocol; + throw new Error(`Unknown stream protocol: ${exhaustiveCheck}`); + } + } +} diff --git a/ui/desktop/src/ai-sdk-fork/core/types/usage.ts b/ui/desktop/src/ai-sdk-fork/core/types/usage.ts new file mode 100644 index 00000000..4f08cb1b --- /dev/null +++ b/ui/desktop/src/ai-sdk-fork/core/types/usage.ts @@ -0,0 +1,13 @@ +export interface LanguageModelUsage { + completionTokens: number; + promptTokens: number; + totalTokens: number; +} + +export function calculateLanguageModelUsage(usage: LanguageModelUsage): LanguageModelUsage { + return { + completionTokens: usage.completionTokens, + promptTokens: usage.promptTokens, + totalTokens: usage.totalTokens, + }; +} diff --git a/ui/desktop/src/ai-sdk-fork/process-custom-chat-response.ts b/ui/desktop/src/ai-sdk-fork/process-custom-chat-response.ts new file mode 100644 index 00000000..01f4c157 --- /dev/null +++ b/ui/desktop/src/ai-sdk-fork/process-custom-chat-response.ts @@ -0,0 +1,229 @@ +import { generateId as generateIdFunction } from '@ai-sdk/provider-utils'; +import type { JSONValue, Message } from '@ai-sdk/ui-utils'; +import { parsePartialJson, processDataStream } from '@ai-sdk/ui-utils'; +import { LanguageModelV1FinishReason } from '@ai-sdk/provider'; +import { LanguageModelUsage } from './core/types/usage'; + +// Simple usage calculation since we don't have access to the original +function calculateLanguageModelUsage(usage: LanguageModelUsage): LanguageModelUsage { + return { + completionTokens: usage.completionTokens, + promptTokens: usage.promptTokens, + totalTokens: usage.totalTokens, + }; +} + +export async function processCustomChatResponse({ + stream, + update, + onToolCall, + onFinish, + generateId = generateIdFunction, + getCurrentDate = () => new Date(), +}: { + stream: ReadableStream; + update: (newMessages: Message[], data: JSONValue[] | undefined) => void; + onToolCall?: (options: { toolCall: any }) => Promise; + onFinish?: (options: { + message: Message | undefined; + finishReason: LanguageModelV1FinishReason; + usage: LanguageModelUsage; + }) => void; + generateId?: () => string; + getCurrentDate?: () => Date; +}) { + const createdAt = getCurrentDate(); + let currentMessage: Message | undefined = undefined; + const previousMessages: Message[] = []; + const data: JSONValue[] = []; + let lastEventType: 'text' | 'tool' | undefined = undefined; + + // Keep track of partial tool calls + const partialToolCalls: Record = {}; + + let usage: LanguageModelUsage = { + completionTokens: NaN, + promptTokens: NaN, + totalTokens: NaN, + }; + let finishReason: LanguageModelV1FinishReason = 'unknown'; + + function execUpdate() { + const copiedData = [...data]; + if (currentMessage == null) { + update(previousMessages, copiedData); + return; + } + + const copiedMessage = { + ...JSON.parse(JSON.stringify(currentMessage)), + revisionId: generateId(), + } as Message; + + update([...previousMessages, copiedMessage], copiedData); + } + + // Create a new message only if needed + function createNewMessage(): Message { + if (currentMessage == null) { + currentMessage = { + id: generateId(), + role: 'assistant', + content: '', + createdAt, + }; + } + return currentMessage; + } + + // Move the current message to previous messages if it exists + function archiveCurrentMessage() { + if (currentMessage != null) { + previousMessages.push(currentMessage); + currentMessage = undefined; + } + } + + await processDataStream({ + stream, + onTextPart(value) { + // If the last event wasn't text, or we don't have a current message, create a new one + if (lastEventType !== 'text' || currentMessage == null) { + archiveCurrentMessage(); + currentMessage = createNewMessage(); + currentMessage.content = value; + } else { + // Concatenate with the existing message + currentMessage.content += value; + } + lastEventType = 'text'; + execUpdate(); + }, + onToolCallStreamingStartPart(value) { + // Always create a new message for tool calls + archiveCurrentMessage(); + currentMessage = createNewMessage(); + lastEventType = 'tool'; + + if (currentMessage.toolInvocations == null) { + currentMessage.toolInvocations = []; + } + + partialToolCalls[value.toolCallId] = { + text: '', + toolName: value.toolName, + index: currentMessage.toolInvocations.length, + }; + + currentMessage.toolInvocations.push({ + state: 'partial-call', + toolCallId: value.toolCallId, + toolName: value.toolName, + args: undefined, + }); + + execUpdate(); + }, + onToolCallDeltaPart(value) { + if (!currentMessage) { + currentMessage = createNewMessage(); + } + lastEventType = 'tool'; + + const partialToolCall = partialToolCalls[value.toolCallId]; + partialToolCall.text += value.argsTextDelta; + + const { value: partialArgs } = parsePartialJson(partialToolCall.text); + + currentMessage.toolInvocations![partialToolCall.index] = { + state: 'partial-call', + toolCallId: value.toolCallId, + toolName: partialToolCall.toolName, + args: partialArgs, + }; + + execUpdate(); + }, + async onToolCallPart(value) { + if (!currentMessage) { + currentMessage = createNewMessage(); + } + lastEventType = 'tool'; + + if (partialToolCalls[value.toolCallId] != null) { + currentMessage.toolInvocations![partialToolCalls[value.toolCallId].index] = { + state: 'call', + ...value, + }; + } else { + if (currentMessage.toolInvocations == null) { + currentMessage.toolInvocations = []; + } + + currentMessage.toolInvocations.push({ + state: 'call', + ...value, + }); + } + + if (onToolCall) { + const result = await onToolCall({ toolCall: value }); + if (result != null) { + currentMessage.toolInvocations![currentMessage.toolInvocations!.length - 1] = { + state: 'result', + ...value, + result, + }; + } + } + + execUpdate(); + }, + onToolResultPart(value) { + if (!currentMessage) { + currentMessage = createNewMessage(); + } + lastEventType = 'tool'; + + const toolInvocations = currentMessage.toolInvocations; + if (toolInvocations == null) { + throw new Error('tool_result must be preceded by a tool_call'); + } + + const toolInvocationIndex = toolInvocations.findIndex( + (invocation) => invocation.toolCallId === value.toolCallId + ); + + if (toolInvocationIndex === -1) { + throw new Error('tool_result must be preceded by a tool_call with the same toolCallId'); + } + + toolInvocations[toolInvocationIndex] = { + ...toolInvocations[toolInvocationIndex], + state: 'result' as const, + ...value, + }; + + execUpdate(); + }, + onDataPart(value) { + data.push(...value); + execUpdate(); + }, + onFinishStepPart() { + // Archive the current message when a step finishes + archiveCurrentMessage(); + }, + onFinishMessagePart(value) { + finishReason = value.finishReason; + if (value.usage != null) { + usage = calculateLanguageModelUsage(value.usage); + } + }, + onErrorPart(error) { + throw new Error(error); + }, + }); + + onFinish?.({ message: currentMessage, finishReason, usage }); +} diff --git a/ui/desktop/src/ai-sdk-fork/throttle.ts b/ui/desktop/src/ai-sdk-fork/throttle.ts new file mode 100644 index 00000000..b95d75c4 --- /dev/null +++ b/ui/desktop/src/ai-sdk-fork/throttle.ts @@ -0,0 +1,5 @@ +import throttleFunction from 'throttleit'; + +export function throttle any>(fn: T, waitMs: number | undefined): T { + return waitMs != null ? throttleFunction(fn, waitMs) : fn; +} diff --git a/ui/desktop/src/ai-sdk-fork/useChat.ts b/ui/desktop/src/ai-sdk-fork/useChat.ts new file mode 100644 index 00000000..a5831ffd --- /dev/null +++ b/ui/desktop/src/ai-sdk-fork/useChat.ts @@ -0,0 +1,611 @@ +import { FetchFunction } from '@ai-sdk/provider-utils'; +import type { + Attachment, + ChatRequest, + ChatRequestOptions, + CreateMessage, + IdGenerator, + JSONValue, + Message, + UseChatOptions, +} from '@ai-sdk/ui-utils'; +import { generateId as generateIdFunc } from '@ai-sdk/ui-utils'; +import { callCustomChatApi as callChatApi } from './call-custom-chat-api'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import useSWR, { KeyedMutator } from 'swr'; +import { throttle } from './throttle'; +import { getSecretKey } from '../config'; + +export type { CreateMessage, Message, UseChatOptions }; + +export type UseChatHelpers = { + /** Current messages in the chat */ + messages: Message[]; + /** The error object of the API request */ + error: undefined | Error; + /** + * Append a user message to the chat list. This triggers the API call to fetch + * the assistant's response. + * @param message The message to append + * @param options Additional options to pass to the API call + */ + append: ( + message: Message | CreateMessage, + chatRequestOptions?: ChatRequestOptions + ) => Promise; + /** + * Reload the last AI chat response for the given chat history. If the last + * message isn't from the assistant, it will request the API to generate a + * new response. + */ + reload: (chatRequestOptions?: ChatRequestOptions) => Promise; + /** + * Abort the current request immediately, keep the generated tokens if any. + */ + stop: () => void; + /** + * Update the `messages` state locally. This is useful when you want to + * edit the messages on the client, and then trigger the `reload` method + * manually to regenerate the AI response. + */ + setMessages: (messages: Message[] | ((messages: Message[]) => Message[])) => void; + /** The current value of the input */ + input: string; + /** setState-powered method to update the input value */ + setInput: React.Dispatch>; + /** An input/textarea-ready onChange handler to control the value of the input */ + handleInputChange: ( + e: React.ChangeEvent | React.ChangeEvent + ) => void; + /** Form submission handler to automatically reset input and append a user message */ + handleSubmit: ( + event?: { preventDefault?: () => void }, + chatRequestOptions?: ChatRequestOptions + ) => void; + metadata?: object; + /** Whether the API request is in progress */ + isLoading: boolean; + + /** Additional data added on the server via StreamData. */ + data?: JSONValue[]; + /** Set the data of the chat. You can use this to transform or clear the chat data. */ + setData: ( + data: JSONValue[] | undefined | ((data: JSONValue[] | undefined) => JSONValue[] | undefined) + ) => void; +}; + +const processResponseStream = async ( + api: string, + chatRequest: ChatRequest, + mutate: KeyedMutator, + mutateStreamData: KeyedMutator, + existingDataRef: React.MutableRefObject, + extraMetadataRef: React.MutableRefObject, + messagesRef: React.MutableRefObject, + abortControllerRef: React.MutableRefObject, + generateId: IdGenerator, + streamProtocol: UseChatOptions['streamProtocol'], + onFinish: UseChatOptions['onFinish'], + onResponse: ((response: Response) => void | Promise) | undefined, + onToolCall: UseChatOptions['onToolCall'] | undefined, + sendExtraMessageFields: boolean | undefined, + experimental_prepareRequestBody: + | ((options: { + messages: Message[]; + requestData?: JSONValue; + requestBody?: object; + }) => JSONValue) + | undefined, + fetch: FetchFunction | undefined, + keepLastMessageOnError: boolean +) => { + // Do an optimistic update to the chat state to show the updated messages immediately: + const previousMessages = messagesRef.current; + mutate(chatRequest.messages, false); + + const constructedMessagesPayload = sendExtraMessageFields + ? chatRequest.messages + : chatRequest.messages.map( + ({ role, content, experimental_attachments, data, annotations, toolInvocations }) => ({ + role, + content, + ...(experimental_attachments !== undefined && { + experimental_attachments, + }), + ...(data !== undefined && { data }), + ...(annotations !== undefined && { annotations }), + ...(toolInvocations !== undefined && { toolInvocations }), + }) + ); + + const existingData = existingDataRef.current; + + return await callChatApi({ + api, + body: experimental_prepareRequestBody?.({ + messages: chatRequest.messages, + requestData: chatRequest.data, + requestBody: chatRequest.body, + }) ?? { + messages: constructedMessagesPayload, + data: chatRequest.data, + ...extraMetadataRef.current.body, + ...chatRequest.body, + }, + streamProtocol, + credentials: extraMetadataRef.current.credentials, + headers: { + ...extraMetadataRef.current.headers, + ...chatRequest.headers, + 'X-Secret-Key': getSecretKey(), + }, + abortController: () => abortControllerRef.current, + restoreMessagesOnFailure() { + if (!keepLastMessageOnError) { + mutate(previousMessages, false); + } + }, + onResponse, + onUpdate(merged, data) { + mutate([...chatRequest.messages, ...merged], false); + if (data?.length) { + mutateStreamData([...(existingData ?? []), ...data], false); + } + }, + onToolCall, + onFinish, + generateId, + fetch, + }); +}; + +export function useChat({ + api = '/api/chat', + id, + initialMessages, + initialInput = '', + sendExtraMessageFields, + onToolCall, + experimental_prepareRequestBody, + maxSteps = 1, + streamProtocol = 'data', + onResponse, + onFinish, + onError, + credentials, + headers, + body, + generateId = generateIdFunc, + fetch, + keepLastMessageOnError = true, + experimental_throttle: throttleWaitMs, +}: UseChatOptions & { + key?: string; + + /** + * Experimental (React only). When a function is provided, it will be used + * to prepare the request body for the chat API. This can be useful for + * customizing the request body based on the messages and data in the chat. + * + * @param messages The current messages in the chat. + * @param requestData The data object passed in the chat request. + * @param requestBody The request body object passed in the chat request. + */ + experimental_prepareRequestBody?: (options: { + messages: Message[]; + requestData?: JSONValue; + requestBody?: object; + }) => JSONValue; + + /** +Custom throttle wait in ms for the chat messages and data updates. +Default is undefined, which disables throttling. + */ + experimental_throttle?: number; + + /** +Maximum number of sequential LLM calls (steps), e.g. when you use tool calls. Must be at least 1. + +A maximum number is required to prevent infinite loops in the case of misconfigured tools. + +By default, it's set to 1, which means that only a single LLM call is made. + */ + maxSteps?: number; +} = {}): UseChatHelpers & { + addToolResult: ({ toolCallId, result }: { toolCallId: string; result: any }) => void; +} { + // Generate a unique id for the chat if not provided. + const hookId = useId(); + const idKey = id ?? hookId; + const chatKey = typeof api === 'string' ? [api, idKey] : idKey; + + // Store a empty array as the initial messages + // (instead of using a default parameter value that gets re-created each time) + // to avoid re-renders: + const [initialMessagesFallback] = useState([]); + + // Store the chat state in SWR, using the chatId as the key to share states. + const { data: messages, mutate } = useSWR([chatKey, 'messages'], null, { + fallbackData: initialMessages ?? initialMessagesFallback, + }); + + // Keep the latest messages in a ref. + const messagesRef = useRef(messages || []); + useEffect(() => { + messagesRef.current = messages || []; + }, [messages]); + + // stream data + const { data: streamData, mutate: mutateStreamData } = useSWR( + [chatKey, 'streamData'], + null + ); + + // keep the latest stream data in a ref + const streamDataRef = useRef(streamData); + useEffect(() => { + streamDataRef.current = streamData; + }, [streamData]); + + // We store loading state in another hook to sync loading states across hook invocations + const { data: isLoading = false, mutate: mutateLoading } = useSWR( + [chatKey, 'loading'], + null + ); + + const { data: error = undefined, mutate: setError } = useSWR( + [chatKey, 'error'], + null + ); + + // Abort controller to cancel the current API call. + const abortControllerRef = useRef(null); + + const extraMetadataRef = useRef({ + credentials, + headers, + body, + }); + + useEffect(() => { + extraMetadataRef.current = { + credentials, + headers, + body, + }; + }, [credentials, headers, body]); + + const triggerRequest = useCallback( + async (chatRequest: ChatRequest) => { + const messageCount = messagesRef.current.length; + + try { + mutateLoading(true); + setError(undefined); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + await processResponseStream( + api, + chatRequest, + // throttle streamed ui updates: + throttle(mutate, throttleWaitMs), + throttle(mutateStreamData, throttleWaitMs), + streamDataRef, + extraMetadataRef, + messagesRef, + abortControllerRef, + generateId, + streamProtocol, + onFinish, + onResponse, + onToolCall, + sendExtraMessageFields, + experimental_prepareRequestBody, + fetch, + keepLastMessageOnError + ); + + abortControllerRef.current = null; + } catch (err) { + // Ignore abort errors as they are expected. + if ((err as any).name === 'AbortError') { + abortControllerRef.current = null; + return null; + } + + if (onError && err instanceof Error) { + onError(err); + } + + setError(err as Error); + } finally { + mutateLoading(false); + } + + // auto-submit when all tool calls in the last assistant message have results: + const messages = messagesRef.current; + const lastMessage = messages[messages.length - 1]; + if ( + // ensure we actually have new messages (to prevent infinite loops in case of errors): + messages.length > messageCount && + // ensure there is a last message: + lastMessage != null && + // check if the feature is enabled: + maxSteps > 1 && + // check that next step is possible: + isAssistantMessageWithCompletedToolCalls(lastMessage) && + // limit the number of automatic steps: + countTrailingAssistantMessages(messages) < maxSteps + ) { + await triggerRequest({ messages }); + } + }, + [ + mutate, + mutateLoading, + api, + extraMetadataRef, + onResponse, + onFinish, + onError, + setError, + mutateStreamData, + streamDataRef, + streamProtocol, + sendExtraMessageFields, + experimental_prepareRequestBody, + onToolCall, + maxSteps, + messagesRef, + abortControllerRef, + generateId, + fetch, + keepLastMessageOnError, + throttleWaitMs, + ] + ); + + const append = useCallback( + async ( + message: Message | CreateMessage, + { data, headers, body, experimental_attachments }: ChatRequestOptions = {} + ) => { + if (!message.id) { + message.id = generateId(); + } + + const attachmentsForRequest = await prepareAttachmentsForRequest(experimental_attachments); + + const messages = messagesRef.current.concat({ + ...message, + id: message.id ?? generateId(), + createdAt: message.createdAt ?? new Date(), + experimental_attachments: + attachmentsForRequest.length > 0 ? attachmentsForRequest : undefined, + }); + + return triggerRequest({ messages, headers, body, data }); + }, + [triggerRequest, generateId] + ); + + const reload = useCallback( + async ({ data, headers, body }: ChatRequestOptions = {}) => { + const messages = messagesRef.current; + + if (messages.length === 0) { + return null; + } + + // Remove last assistant message and retry last user message. + const lastMessage = messages[messages.length - 1]; + return triggerRequest({ + messages: lastMessage.role === 'assistant' ? messages.slice(0, -1) : messages, + headers, + body, + data, + }); + }, + [triggerRequest] + ); + + const stop = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + const setMessages = useCallback( + (messages: Message[] | ((messages: Message[]) => Message[])) => { + if (typeof messages === 'function') { + messages = messages(messagesRef.current); + } + + mutate(messages, false); + messagesRef.current = messages; + }, + [mutate] + ); + + const setData = useCallback( + ( + data: JSONValue[] | undefined | ((data: JSONValue[] | undefined) => JSONValue[] | undefined) + ) => { + if (typeof data === 'function') { + data = data(streamDataRef.current); + } + + mutateStreamData(data, false); + streamDataRef.current = data; + }, + [mutateStreamData] + ); + + // Input state and handlers. + const [input, setInput] = useState(initialInput); + + const handleSubmit = useCallback( + async ( + event?: { preventDefault?: () => void }, + options: ChatRequestOptions = {}, + metadata?: object + ) => { + event?.preventDefault?.(); + + if (!input && !options.allowEmptySubmit) return; + + if (metadata) { + extraMetadataRef.current = { + ...extraMetadataRef.current, + ...metadata, + }; + } + + const attachmentsForRequest = await prepareAttachmentsForRequest( + options.experimental_attachments + ); + + const messages = + !input && !attachmentsForRequest.length && options.allowEmptySubmit + ? messagesRef.current + : messagesRef.current.concat({ + id: generateId(), + createdAt: new Date(), + role: 'user', + content: input, + experimental_attachments: + attachmentsForRequest.length > 0 ? attachmentsForRequest : undefined, + }); + + const chatRequest: ChatRequest = { + messages, + headers: options.headers, + body: options.body, + data: options.data, + }; + + triggerRequest(chatRequest); + + setInput(''); + }, + [input, generateId, triggerRequest] + ); + + const handleInputChange = (e: any) => { + setInput(e.target.value); + }; + + const addToolResult = ({ toolCallId, result }: { toolCallId: string; result: any }) => { + const updatedMessages = messagesRef.current.map((message, index, arr) => + // update the tool calls in the last assistant message: + index === arr.length - 1 && message.role === 'assistant' && message.toolInvocations + ? { + ...message, + toolInvocations: message.toolInvocations.map((toolInvocation) => + toolInvocation.toolCallId === toolCallId + ? { + ...toolInvocation, + result, + state: 'result' as const, + } + : toolInvocation + ), + } + : message + ); + + mutate(updatedMessages, false); + + // auto-submit when all tool calls in the last assistant message have results: + const lastMessage = updatedMessages[updatedMessages.length - 1]; + if (isAssistantMessageWithCompletedToolCalls(lastMessage)) { + triggerRequest({ messages: updatedMessages }); + } + }; + + return { + messages: messages || [], + setMessages, + data: streamData, + setData, + error, + append, + reload, + stop, + input, + setInput, + handleInputChange, + handleSubmit, + isLoading, + addToolResult, + }; +} + +/** +Check if the message is an assistant message with completed tool calls. +The message must have at least one tool invocation and all tool invocations +must have a result. + */ +function isAssistantMessageWithCompletedToolCalls(message: Message) { + return ( + message.role === 'assistant' && + message.toolInvocations && + message.toolInvocations.length > 0 && + message.toolInvocations.every((toolInvocation) => 'result' in toolInvocation) + ); +} + +/** +Returns the number of trailing assistant messages in the array. + */ +function countTrailingAssistantMessages(messages: Message[]) { + let count = 0; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'assistant') { + count++; + } else { + break; + } + } + return count; +} + +async function prepareAttachmentsForRequest( + attachmentsFromOptions: FileList | Array | undefined +) { + if (attachmentsFromOptions == null) { + return []; + } + + if (attachmentsFromOptions instanceof FileList) { + return Promise.all( + Array.from(attachmentsFromOptions).map(async (attachment) => { + const { name, type } = attachment; + + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (readerEvent) => { + resolve(readerEvent.target?.result as string); + }; + reader.onerror = (error) => reject(error); + reader.readAsDataURL(attachment); + }); + + return { + name, + contentType: type, + url: dataUrl, + }; + }) + ); + } + + if (Array.isArray(attachmentsFromOptions)) { + return attachmentsFromOptions; + } + + throw new Error('Invalid attachments type'); +} diff --git a/src/goose/__init__.py b/ui/desktop/src/bin/.gitkeep similarity index 100% rename from src/goose/__init__.py rename to ui/desktop/src/bin/.gitkeep diff --git a/ui/desktop/src/bin/npx b/ui/desktop/src/bin/npx new file mode 100755 index 00000000..169e3fb3 --- /dev/null +++ b/ui/desktop/src/bin/npx @@ -0,0 +1,98 @@ +#!/bin/bash + +# Enable strict mode to exit on errors and unset variables +set -euo pipefail + +# Set log file +LOG_FILE="/tmp/mcp.log" + +# Clear the log file at the start +> "$LOG_FILE" + +# Function for logging +log() { + local MESSAGE="$1" + echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE" +} + +# Trap errors and log them before exiting +trap 'log "An error occurred. Exiting with status $?."' ERR + +log "Starting npx setup script." + +# Ensure ~/.config/goose/mcp-hermit/bin exists +log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." +mkdir -p ~/.config/goose/mcp-hermit/bin + +# Change to the ~/.config/goose/mcp-hermit directory +log "Changing to directory ~/.config/goose/mcp-hermit." +cd ~/.config/goose/mcp-hermit + +# Check if hermit binary exists and download if not +if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then + log "Hermit binary not found. Downloading hermit binary." + curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ + | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit + log "Hermit binary downloaded and made executable." +else + log "Hermit binary already exists. Skipping download." +fi + +# Update PATH +export PATH=~/.config/goose/mcp-hermit/bin:$PATH +log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." + + +# Verify hermit installation +log "Checking for hermit in PATH." +which hermit >> "$LOG_FILE" + +# Initialize hermit +log "Initializing hermit." +hermit init >> "$LOG_FILE" + +# Install Node.js using hermit +log "Installing Node.js with hermit." +hermit install node >> "$LOG_FILE" + +# Verify installations +log "Verifying installation locations:" +log "hermit: $(which hermit)" +log "node: $(which node)" +log "npx: $(which npx)" + + +log "Checking for GOOSE_NPM_REGISTRY and GOOSE_NPM_CERT environment variables for custom npm registry setup..." +# Check if GOOSE_NPM_REGISTRY is set and accessible +if [ -n "${GOOSE_NPM_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_NPM_REGISTRY" > /dev/null; then + log "Checking custom goose registry availability: $GOOSE_NPM_REGISTRY" + log "$GOOSE_NPM_REGISTRY is accessible. Using it for npm registry." + export NPM_CONFIG_REGISTRY="$GOOSE_NPM_REGISTRY" + + # Check if GOOSE_NPM_CERT is set and accessible + if [ -n "${GOOSE_NPM_CERT:-}" ] && curl -s --head --fail "$GOOSE_NPM_CERT" > /dev/null; then + log "Downloading certificate from: $GOOSE_NPM_CERT" + curl -sSL -o ~/.config/goose/mcp-hermit/cert.pem "$GOOSE_NPM_CERT" + if [ $? -eq 0 ]; then + log "Certificate downloaded successfully." + export NODE_EXTRA_CA_CERTS=~/.config/goose/mcp-hermit/cert.pem + else + log "Unable to download the certificate. Skipping certificate setup." + fi + else + log "GOOSE_NPM_CERT is either not set or not accessible. Skipping certificate setup." + fi + +else + log "GOOSE_NPM_REGISTRY is either not set or not accessible. Falling back to default npm registry." + export NPM_CONFIG_REGISTRY="https://registry.npmjs.org/" +fi + + + + +# Final step: Execute npx with passed arguments +log "Executing 'npx' command with arguments: $*" +npx "$@" || log "Failed to execute 'npx' with arguments: $*" + +log "npx setup script completed successfully." \ No newline at end of file diff --git a/ui/desktop/src/bin/uvx b/ui/desktop/src/bin/uvx new file mode 100755 index 00000000..88b9fcec --- /dev/null +++ b/ui/desktop/src/bin/uvx @@ -0,0 +1,99 @@ +#!/bin/bash + +# Enable strict mode to exit on errors and unset variables +set -euo pipefail + +# Set log file +LOG_FILE="/tmp/mcp.log" + +# Clear the log file at the start +> "$LOG_FILE" + +# Function for logging +log() { + local MESSAGE="$1" + echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE" +} + +# Trap errors and log them before exiting +trap 'log "An error occurred. Exiting with status $?."' ERR + +log "Starting uvx setup script." + +# Ensure ~/.config/goose/mcp-hermit/bin exists +log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." +mkdir -p ~/.config/goose/mcp-hermit/bin + +# Change to the ~/.config/goose/mcp-hermit directory +log "Changing to directory ~/.config/goose/mcp-hermit." +cd ~/.config/goose/mcp-hermit + +# Check if hermit binary exists and download if not +if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then + log "Hermit binary not found. Downloading hermit binary." + curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ + | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit + log "Hermit binary downloaded and made executable." +else + log "Hermit binary already exists. Skipping download." +fi + +# Update PATH +export PATH=~/.config/goose/mcp-hermit/bin:$PATH +log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." + + +# Verify hermit installation +log "Checking for hermit in PATH." +which hermit >> "$LOG_FILE" + +# Initialize hermit +log "Initializing hermit." +hermit init >> "$LOG_FILE" + +# Install UV for python using hermit +log "Installing UV with hermit." +hermit install uv >> "$LOG_FILE" + +# Verify installations +log "Verifying installation locations:" +log "hermit: $(which hermit)" +log "uv: $(which uv)" +log "uvx: $(which uvx)" + + +log "Checking for GOOSE_UV_REGISTRY and GOOSE_UV_CERT environment variables for custom python/pip/UV registry setup..." +# Check if GOOSE_UV_REGISTRY is set and accessible +if [ -n "${GOOSE_UV_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_UV_REGISTRY" > /dev/null; then + log "Checking custom goose registry availability: $GOOSE_UV_REGISTRY" + log "$GOOSE_UV_REGISTRY is accessible. Using it for UV registry." + export UV_INDEX_URL="$GOOSE_UV_REGISTRY" + + if [ -n "${GOOSE_UV_CERT:-}" ] && curl -s --head --fail "$GOOSE_UV_CERT" > /dev/null; then + log "Downloading certificate from: $GOOSE_UV_CERT" + curl -sSL -o ~/.config/goose/mcp-hermit/cert.pem "$GOOSE_UV_CERT" + if [ $? -eq 0 ]; then + log "Certificate downloaded successfully." + export SSL_CLIENT_CERT=~/.config/goose/mcp-hermit/cert.pem + else + log "Unable to download the certificate. Skipping certificate setup." + fi + else + log "GOOSE_UV_CERT is either not set or not accessible. Skipping certificate setup." + fi + +else + log "GOOSE_UV_REGISTRY is either not set or not accessible. Falling back to default pip registry." + export UV_INDEX_URL="https://pypi.org/simple" +fi + + + + + + +# Final step: Execute uvx with passed arguments +log "Executing 'uvx' command with arguments: $*" +uvx "$@" || log "Failed to execute 'uvx' with arguments: $*" + +log "uvx setup script completed successfully." \ No newline at end of file diff --git a/ui/desktop/src/components/ApiKeyWarning.tsx b/ui/desktop/src/components/ApiKeyWarning.tsx new file mode 100644 index 00000000..3821ff38 --- /dev/null +++ b/ui/desktop/src/components/ApiKeyWarning.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Card } from './ui/card'; +import { Bird } from './ui/icons'; +import { ChevronDown } from './icons'; + +interface ApiKeyWarningProps { + className?: string; +} + +interface CollapsibleProps { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; +} + +function Collapsible({ title, children, defaultOpen = false }: CollapsibleProps) { + const [isOpen, setIsOpen] = React.useState(defaultOpen); + + return ( +
+ + {isOpen &&
{children}
} +
+ ); +} + +const OPENAI_CONFIG = `export GOOSE_PROVIDER__TYPE=openai +export GOOSE_PROVIDER__HOST=https://api.openai.com +export GOOSE_PROVIDER__MODEL=gpt-4o +export GOOSE_PROVIDER__API_KEY=your_api_key_here`; + +const ANTHROPIC_CONFIG = `export GOOSE_PROVIDER__TYPE=anthropic +export GOOSE_PROVIDER__HOST=https://api.anthropic.com +export GOOSE_PROVIDER__MODEL=claude-3-5-sonnet-latest +export GOOSE_PROVIDER__API_KEY=your_api_key_here`; + +const DATABRICKS_CONFIG = `export GOOSE_PROVIDER__TYPE=databricks +export GOOSE_PROVIDER__HOST=your_databricks_host +export GOOSE_PROVIDER__MODEL=your_databricks_model`; + +const OPENROUTER_CONFIG = `export GOOSE_PROVIDER__TYPE=openrouter +export GOOSE_PROVIDER__HOST=https://openrouter.ai +export GOOSE_PROVIDER__MODEL=anthropic/claude-3.5-sonnet +export GOOSE_PROVIDER__API_KEY=your_api_key_here`; + +export function ApiKeyWarning({ className }: ApiKeyWarningProps) { + return ( + +
+ +
+
+

Credentials Required

+

+ To use Goose, you need to set environment variables for one of the following providers: +

+ +
+ +
{OPENAI_CONFIG}
+
+ + +
{ANTHROPIC_CONFIG}
+
+ + +
{DATABRICKS_CONFIG}
+
+ + +
{OPENROUTER_CONFIG}
+
+
+

+ After setting these variables, restart Goose for the changes to take effect. +

+
+
+ ); +} diff --git a/ui/desktop/src/components/BottomMenu.tsx b/ui/desktop/src/components/BottomMenu.tsx new file mode 100644 index 00000000..0d2efc21 --- /dev/null +++ b/ui/desktop/src/components/BottomMenu.tsx @@ -0,0 +1,136 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useModel } from './settings/models/ModelContext'; +import { useRecentModels } from './settings/models/RecentModels'; // Hook for recent models +import { Sliders } from 'lucide-react'; +import { ModelRadioList } from './settings/models/ModelRadioList'; +import { useNavigate } from 'react-router-dom'; +import { Document, ChevronUp, ChevronDown } from './icons'; + +export default function BottomMenu({ hasMessages }) { + const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + const { currentModel } = useModel(); + const { recentModels } = useRecentModels(); // Get recent models + const navigate = useNavigate(); + const dropdownRef = useRef(null); + + // Add effect to handle clicks outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsModelMenuOpen(false); + } + }; + + if (isModelMenuOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isModelMenuOpen]); + + // Add effect to handle Escape key + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsModelMenuOpen(false); + } + }; + + if (isModelMenuOpen) { + window.addEventListener('keydown', handleEsc); + } + + return () => { + window.removeEventListener('keydown', handleEsc); + }; + }, [isModelMenuOpen]); + + return ( +
+ {/* Directory Chooser - Always visible */} + { + console.log('Opening directory chooser'); + if (hasMessages) { + window.electron.directoryChooser(); + } else { + window.electron.directoryChooser(true); + } + }} + > + + Working in {window.appConfig.get('GOOSE_WORKING_DIR')} + + + + {/* Model Selector Dropdown - Only in development */} +
+
setIsModelMenuOpen(!isModelMenuOpen)} + > + {currentModel?.name || 'Select Model'} + {isModelMenuOpen ? ( + + ) : ( + + )} +
+ + {/* Dropdown Menu */} + {isModelMenuOpen && ( +
+
+ ( + + )} + /> +
{ + setIsModelMenuOpen(false); + navigate('/settings'); + }} + > + Tools and Settings + +
+
+
+ )} +
+
+ ); +} diff --git a/ui/desktop/src/components/ErrorScreen.tsx b/ui/desktop/src/components/ErrorScreen.tsx new file mode 100644 index 00000000..fba2e3ff --- /dev/null +++ b/ui/desktop/src/components/ErrorScreen.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Card } from './ui/card'; + +interface ErrorScreenProps { + error: string; + onReload: () => void; +} + +const ErrorScreen: React.FC = ({ error, onReload }) => { + return ( +
+
+
+ +
+
+ {'Honk! Goose experienced a fatal error'} +
+
+ Reload +
+
+
+
+ ); +}; + +export default ErrorScreen; diff --git a/ui/desktop/src/components/FlappyGoose.tsx b/ui/desktop/src/components/FlappyGoose.tsx new file mode 100644 index 00000000..8b9a7a1c --- /dev/null +++ b/ui/desktop/src/components/FlappyGoose.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useRef, useState } from 'react'; + +declare var requestAnimationFrame: (callback: FrameRequestCallback) => number; +declare class HTMLCanvasElement {} +declare class HTMLImageElement {} +declare class DOMHighResTimeStamp {} +declare class Image {} +declare type FrameRequestCallback = (time: DOMHighResTimeStamp) => void; +import svg1 from '../images/loading-goose/1.svg'; +import svg7 from '../images/loading-goose/7.svg'; + +interface Obstacle { + x: number; + gapY: number; + passed: boolean; +} + +interface FlappyGooseProps { + onClose: () => void; +} + +const FlappyGoose: React.FC = ({ onClose }) => { + const canvasRef = useRef(null); + const [gameOver, setGameOver] = useState(false); + const [displayScore, setDisplayScore] = useState(0); + const gooseImages = useRef([]); + const framesLoaded = useRef(0); + const [imagesReady, setImagesReady] = useState(false); + + // Game state + const gameState = useRef({ + gooseY: 200, + velocity: 0, + obstacles: [] as Obstacle[], + gameLoop: 0, + running: false, + score: 0, + isFlapping: false, + flapEndTime: 0, + }); + + // Game settings + const CANVAS_WIDTH = 600; + const CANVAS_HEIGHT = 400; + const GRAVITY = 0.35; + const FLAP_FORCE = -7; + const OBSTACLE_SPEED = 2.5; + const OBSTACLE_GAP = 180; + const GOOSE_SIZE = 35; + const GOOSE_X = 50; + const OBSTACLE_WIDTH = 40; + const FLAP_DURATION = 150; + + const safeRequestAnimationFrame = (callback: FrameRequestCallback) => { + if (typeof window !== 'undefined' && typeof requestAnimationFrame !== 'undefined') { + requestAnimationFrame(callback); + } + }; + + // Load goose images + useEffect(() => { + const frames = [svg1, svg7]; + frames.forEach((src, index) => { + const img = new Image(); + img.src = src; + img.onload = () => { + framesLoaded.current += 1; + if (framesLoaded.current === frames.length) { + setImagesReady(true); + } + }; + gooseImages.current[index] = img; + }); + }, []); + + const startGame = () => { + if (gameState.current.running || !imagesReady || typeof window === 'undefined') return; + + gameState.current = { + gooseY: CANVAS_HEIGHT / 3, + velocity: 0, + obstacles: [], + gameLoop: 0, + running: true, + score: 0, + isFlapping: false, + flapEndTime: 0, + }; + setGameOver(false); + setDisplayScore(0); + safeRequestAnimationFrame(gameLoop); + }; + + const flap = () => { + if (gameOver) { + startGame(); + return; + } + gameState.current.velocity = FLAP_FORCE; + gameState.current.isFlapping = true; + gameState.current.flapEndTime = Date.now() + FLAP_DURATION; + }; + + const gameLoop = () => { + if (!gameState.current.running || !imagesReady) return; + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Check if flap animation should end + if (gameState.current.isFlapping && Date.now() >= gameState.current.flapEndTime) { + gameState.current.isFlapping = false; + } + + // Update goose position + gameState.current.velocity += GRAVITY; + gameState.current.gooseY += gameState.current.velocity; + + // Generate obstacles + if (gameState.current.gameLoop % 120 === 0) { + gameState.current.obstacles.push({ + x: CANVAS_WIDTH, + gapY: Math.random() * (CANVAS_HEIGHT - OBSTACLE_GAP - 100) + 50, + passed: false, + }); + } + + // Update obstacles and check for score + gameState.current.obstacles = gameState.current.obstacles.filter((obstacle) => { + obstacle.x -= OBSTACLE_SPEED; + + // Check for score when the goose passes the middle of the obstacle + const obstacleMiddle = obstacle.x + OBSTACLE_WIDTH / 2; + const gooseMiddle = GOOSE_X + GOOSE_SIZE / 2; + + if (!obstacle.passed && obstacleMiddle < gooseMiddle) { + obstacle.passed = true; + gameState.current.score += 1; + setDisplayScore(gameState.current.score); + } + + return obstacle.x > -OBSTACLE_WIDTH; + }); + + // Check collisions + const gooseBox = { + x: GOOSE_X, + y: gameState.current.gooseY, + width: GOOSE_SIZE, + height: GOOSE_SIZE, + }; + + // Collision with ground or ceiling + if (gameState.current.gooseY < 0 || gameState.current.gooseY > CANVAS_HEIGHT - GOOSE_SIZE) { + handleGameOver(); + return; + } + + // Collision with obstacles + for (const obstacle of gameState.current.obstacles) { + if (gooseBox.x < obstacle.x + OBSTACLE_WIDTH && gooseBox.x + gooseBox.width > obstacle.x) { + if ( + gooseBox.y < obstacle.gapY - OBSTACLE_GAP / 2 || + gooseBox.y + gooseBox.height > obstacle.gapY + OBSTACLE_GAP / 2 + ) { + handleGameOver(); + return; + } + } + } + + // Draw game + ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + // Draw rotated goose + ctx.save(); + ctx.translate(GOOSE_X + GOOSE_SIZE / 2, gameState.current.gooseY + GOOSE_SIZE / 2); + const rotation = Math.min(Math.max(gameState.current.velocity * 0.05, -0.5), 0.5); + ctx.rotate(rotation); + ctx.drawImage( + gooseImages.current[gameState.current.isFlapping ? 1 : 0], + -GOOSE_SIZE / 2, + -GOOSE_SIZE / 2, + GOOSE_SIZE, + GOOSE_SIZE + ); + ctx.restore(); + + // Draw obstacles + ctx.fillStyle = '#4CAF50'; + gameState.current.obstacles.forEach((obstacle) => { + // Top obstacle + ctx.fillRect(obstacle.x, 0, OBSTACLE_WIDTH, obstacle.gapY - OBSTACLE_GAP / 2); + // Bottom obstacle + ctx.fillRect( + obstacle.x, + obstacle.gapY + OBSTACLE_GAP / 2, + OBSTACLE_WIDTH, + CANVAS_HEIGHT - (obstacle.gapY + OBSTACLE_GAP / 2) + ); + }); + + // Draw score + ctx.fillStyle = '#000'; + ctx.font = '24px Arial'; + ctx.fillText(`Score: ${gameState.current.score}`, 10, 30); + + gameState.current.gameLoop++; + safeRequestAnimationFrame(gameLoop); + }; + + const handleGameOver = () => { + gameState.current.running = false; + setGameOver(true); + }; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + + const handleKeyPress = (e: KeyboardEvent) => { + if (e.code === 'Space') { + e.preventDefault(); + flap(); + } + }; + + window.addEventListener('keydown', handleKeyPress); + + if (imagesReady) { + startGame(); + } + + return () => { + window.removeEventListener('keydown', handleKeyPress); + gameState.current.running = false; + }; + }, [imagesReady]); + + return ( +
+ + {!imagesReady &&
Loading...
} + {gameOver && ( +
+

Game Over!

+

Score: {displayScore}

+

Click or press space to play again

+
+ )} + +
+ ); +}; + +export default FlappyGoose; diff --git a/ui/desktop/src/components/GooseLogo.tsx b/ui/desktop/src/components/GooseLogo.tsx new file mode 100644 index 00000000..5a8d3f8e --- /dev/null +++ b/ui/desktop/src/components/GooseLogo.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Goose, Rain } from './icons/Goose'; + +export default function GooseLogo({ className = '', size = 'default', hover = true }) { + const sizes = { + default: { + frame: 'w-16 h-16', + rain: 'w-[275px] h-[275px]', + goose: 'w-16 h-16', + }, + small: { + frame: 'w-8 h-8', + rain: 'w-[150px] h-[150px]', + goose: 'w-8 h-8', + }, + }; + return ( +
+ + +
+ ); +} diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx new file mode 100644 index 00000000..5195b48e --- /dev/null +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import ToolInvocations from './ToolInvocations'; +import LinkPreview from './LinkPreview'; +import GooseResponseForm from './GooseResponseForm'; +import { extractUrls } from '../utils/urlUtils'; +import MarkdownContent from './MarkdownContent'; + +interface GooseMessageProps { + message: any; + messages: any[]; + metadata?: any; + append: (value: any) => void; +} + +export default function GooseMessage({ message, metadata, messages, append }: GooseMessageProps) { + // Extract URLs under a few conditions + // 1. The message is purely text + // 2. The link wasn't also present in the previous message + // 3. The message contains the explicit http:// or https:// protocol at the beginning + const messageIndex = messages?.findIndex((msg) => msg.id === message.id); + const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null; + const previousUrls = previousMessage ? extractUrls(previousMessage.content) : []; + const urls = !message.toolInvocations ? extractUrls(message.content, previousUrls) : []; + + return ( +
+
+ {message.content && ( +
+ +
+ )} + + {message.toolInvocations && ( +
+ +
+ )} +
+ + {urls.length > 0 && ( +
+ {urls.map((url, index) => ( + + ))} +
+ )} + + {/* enable or disable prompts here */} + {/* NOTE from alexhancock on 1/14/2025 - disabling again temporarily due to non-determinism in when the forms show up */} + {false && metadata && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/GooseResponseForm.tsx b/ui/desktop/src/components/GooseResponseForm.tsx new file mode 100644 index 00000000..3f576d61 --- /dev/null +++ b/ui/desktop/src/components/GooseResponseForm.tsx @@ -0,0 +1,250 @@ +import React, { useState, useEffect, useRef } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { Button } from './ui/button'; +import { cn } from '../utils'; +import { Send } from './icons'; + +interface FormField { + label: string; + type: 'text' | 'textarea'; + name: string; + placeholder: string; + required: boolean; +} + +interface DynamicForm { + title: string; + description: string; + fields: FormField[]; +} + +interface GooseResponseFormProps { + message: string; + metadata: any; + append: (value: any) => void; +} + +export default function GooseResponseForm({ + message: _message, + metadata, + append, +}: GooseResponseFormProps) { + const [selectedOption, setSelectedOption] = useState(null); + const [formValues, setFormValues] = useState>({}); + const prevStatusRef = useRef(null); + + let isQuestion = false; + let isOptions = false; + let options: Array<{ optionTitle: string; optionDescription: string }> = []; + let dynamicForm: DynamicForm | null = null; + + if (metadata) { + window.electron.logInfo('metadata:' + JSON.stringify(metadata, null, 2)); + } + + // Process metadata outside of conditional + const currentStatus = metadata?.[0] ?? null; + isQuestion = currentStatus === 'QUESTION'; + isOptions = metadata?.[1] === 'OPTIONS'; + + // Parse dynamic form data if it exists in metadata[3] + if (metadata?.[3]) { + try { + dynamicForm = JSON.parse(metadata[3]); + } catch (err) { + console.error('Failed to parse form data:', err); + dynamicForm = null; + } + } + + if (isQuestion && isOptions && metadata?.[2]) { + try { + let optionsData = metadata[2]; + // Use a regular expression to extract the JSON block + const jsonBlockMatch = optionsData.match(/```json([\s\S]*?)```/); + + // If a JSON block is found, extract and clean it + if (jsonBlockMatch) { + optionsData = jsonBlockMatch[1].trim(); // Extract the content inside the block + } else { + // Optionally, handle the case where there is no explicit ```json block + console.warn('No JSON block found in the provided string.'); + } + options = JSON.parse(optionsData); + options = options.filter( + (opt) => typeof opt.optionTitle === 'string' && typeof opt.optionDescription === 'string' + ); + } catch (err) { + console.error('Failed to parse options data:', err); + options = []; + } + } + + // Move useEffect to top level + useEffect(() => { + const currentMetadataStatus = metadata?.[0]; + const shouldNotify = + currentMetadataStatus && + (currentMetadataStatus === 'QUESTION' || currentMetadataStatus === 'OPTIONS') && + prevStatusRef.current !== currentMetadataStatus; + + if (shouldNotify) { + window.electron.showNotification({ + title: 'Goose has a question for you', + body: `Please check with Goose to approve the plan of action`, + }); + } + + prevStatusRef.current = currentMetadataStatus ?? null; + }, [metadata]); + + const handleOptionClick = (index: number) => { + setSelectedOption(index); + }; + + const handleAccept = () => { + const message = { + content: 'Yes - go ahead.', + role: 'user', + }; + append(message); + }; + + const handleSubmit = () => { + if (selectedOption !== null && options[selectedOption]) { + const message = { + content: `Yes - continue with: ${options[selectedOption].optionTitle}`, + role: 'user', + }; + append(message); + } + }; + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (dynamicForm) { + const message = { + content: JSON.stringify(formValues), + role: 'user', + }; + append(message); + } + }; + + const handleFormChange = (name: string, value: string) => { + setFormValues((prev) => ({ + ...prev, + [name]: value, + })); + }; + + if (!metadata) { + return null; + } + + function isForm(f: DynamicForm) { + return ( + f && f.title && f.description && f.fields && Array.isArray(f.fields) && f.fields.length > 0 + ); + } + + return ( +
+ {isQuestion && !isOptions && !isForm(dynamicForm) && ( +
+ +
+ )} + {isQuestion && isOptions && Array.isArray(options) && options.length > 0 && ( +
+ {options.map((opt, index) => ( +
handleOptionClick(index)} + className={cn( + 'p-4 rounded-lg border transition-colors cursor-pointer', + selectedOption === index + ? 'bg-primary/10 dark:bg-dark-primary border-primary dark:border-dark-primary' + : 'bg-tool-card dark:bg-tool-card-dark hover:bg-accent dark:hover:bg-dark-accent' + )} + > +

{opt.optionTitle}

+
+ {opt.optionDescription} +
+
+ ))} + +
+ )} + {isForm(dynamicForm) && !isOptions && ( +
+

{dynamicForm.title}

+

{dynamicForm.description}

+ + {dynamicForm.fields.map((field) => ( +
+ + {field.type === 'textarea' ? ( +