diff --git a/.env.example b/.env.example index 68335e7298..924146613c 100644 --- a/.env.example +++ b/.env.example @@ -339,6 +339,7 @@ BROWSER_INACTIVITY_TIMEOUT=120 # TELEGRAM_ALLOWED_USERS= # Comma-separated user IDs # TELEGRAM_HOME_CHANNEL= # Default chat for cron delivery # TELEGRAM_HOME_CHANNEL_NAME= # Display name for home channel +# TELEGRAM_CRON_THREAD_ID= # Forum topic ID for cron deliveries; overrides TELEGRAM_HOME_CHANNEL_THREAD_ID for cron so replies work in topic mode # Webhook mode (optional โ€” for cloud deployments like Fly.io/Railway) # Default is long polling. Setting TELEGRAM_WEBHOOK_URL switches to webhook mode. diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index 3ca4991c61..939215ed44 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -16,7 +16,7 @@ jobs: check-attribution: runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # Full history needed for git log diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 8df74c0509..e18826c517 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -35,7 +35,7 @@ jobs: name: github-pages url: ${{ steps.deploy.outputs.page_url }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: @@ -43,7 +43,7 @@ jobs: cache: npm cache-dependency-path: website/package-lock.json - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index cccb8f3b45..df6fa29d7e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -54,7 +54,7 @@ jobs: digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive @@ -65,7 +65,7 @@ jobs: # to gha with a per-arch scope; the push step below reuses every # layer from this build. - name: Build image (amd64, smoke test) - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile @@ -82,7 +82,7 @@ jobs: - name: Log in to Docker Hub if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -99,7 +99,7 @@ jobs: - name: Push amd64 by digest id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile @@ -142,7 +142,7 @@ jobs: digest: ${{ steps.push.outputs.digest }} steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive @@ -153,7 +153,7 @@ jobs: # to gha with a per-arch scope; the push step below reuses every # layer from this build. - name: Build image (arm64, smoke test) - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile @@ -170,7 +170,7 @@ jobs: - name: Log in to Docker Hub if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -178,7 +178,7 @@ jobs: - name: Push arm64 by digest id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile @@ -232,7 +232,7 @@ jobs: uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -324,7 +324,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1000 @@ -332,7 +332,7 @@ jobs: uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -445,7 +445,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1000 @@ -453,7 +453,7 @@ jobs: uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 80fe9ea9d4..49111b5ac0 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -14,7 +14,7 @@ jobs: docs-site-checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: @@ -26,7 +26,7 @@ jobs: run: npm ci working-directory: website - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml index bd66f19404..46f5368f79 100644 --- a/.github/workflows/history-check.yml +++ b/.github/workflows/history-check.yml @@ -24,7 +24,7 @@ jobs: check-common-ancestor: runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # full history both sides for merge-base diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 807d5b6b69..013d212020 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,7 +37,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # need full history for merge-base + worktree @@ -167,7 +167,7 @@ jobs: timeout-minutes: 5 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 @@ -191,10 +191,10 @@ jobs: timeout-minutes: 5 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: "3.11" diff --git a/.github/workflows/nix-lockfile-fix.yml b/.github/workflows/nix-lockfile-fix.yml index 147339050b..36b86f18c9 100644 --- a/.github/workflows/nix-lockfile-fix.yml +++ b/.github/workflows/nix-lockfile-fix.yml @@ -56,7 +56,7 @@ jobs: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main token: ${{ steps.app-token.outputs.token }} @@ -194,7 +194,7 @@ jobs: Triggered by @${{ github.actor }} โ€” [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }} ref: ${{ steps.resolve.outputs.ref }} diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 9a8f45a7c1..9cb3171aec 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/nix-setup with: cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index db8c3d75ce..099dfc0e35 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -56,7 +56,7 @@ permissions: jobs: scan: name: Scan lockfiles - uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5 # v2.3.5 + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 with: # Scan explicit lockfiles rather than recursing, so we only look at # the three sources of truth and skip vendored / test / worktree dirs. diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index 8beda195c6..6d43a68249 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -20,9 +20,9 @@ jobs: if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' @@ -53,7 +53,7 @@ jobs: # Only deploy on schedule or manual trigger (not on every push to the script) if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -66,7 +66,7 @@ jobs: cache: npm cache-dependency-path: website/package-lock.json - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 69a9a115c8..9eb76e6a5f 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -145,7 +145,7 @@ jobs: if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index be14f14c80..c915485176 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,10 +23,10 @@ concurrency: jobs: test: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y ripgrep @@ -46,7 +46,7 @@ jobs: - name: Run tests run: | source .venv/bin/activate - python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto + python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto --timeout=30 --timeout-method=signal env: # Ensure tests don't accidentally call real APIs OPENROUTER_API_KEY: "" @@ -58,7 +58,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y ripgrep diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 86e7ae477b..9d1806d6f7 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -27,7 +27,7 @@ jobs: name: Build distribution ๐Ÿ“ฆ runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # On workflow_dispatch, check out the confirmed tag. @@ -43,7 +43,7 @@ jobs: fi - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' @@ -145,7 +145,7 @@ jobs: - name: Sign with Sigstore if: env.skip_sign != 'true' - uses: sigstore/gh-action-sigstore-python@f514d46b907ebcd5bedc05145c03b69c1edd8b46 # v3.0.0 + uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0 with: inputs: >- ./dist/*.tar.gz diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 190a162533..37c31799be 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -71,7 +71,7 @@ jobs: timeout-minutes: 5 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 diff --git a/AGENTS.md b/AGENTS.md index 623bb9f6c7..6c3d6cc081 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -855,10 +855,11 @@ kanban task. `unlink`, `comment`, `complete`, `block`, `unblock`, `archive`, `tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`, `assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`. -- **Worker toolset:** `tools/kanban_tools.py` exposes `kanban_show`, - `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, - `kanban_create`, `kanban_link` โ€” gated by `HERMES_KANBAN_TASK` so - the schema only appears for processes actually running as a worker. +- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes + `kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, + `kanban_comment`, `kanban_create`, `kanban_link`; profiles that + explicitly enable the `kanban` toolset outside a dispatcher-spawned + task also get `kanban_list` and `kanban_unblock` for board routing. - **Dispatcher:** long-lived loop that (default every 60s) reclaims stale claims, promotes ready tasks, atomically claims, and spawns assigned profiles. Runs **inside the gateway** by default via @@ -874,8 +875,9 @@ Isolation model: - **Tenant** is a soft namespace *within* a board โ€” one specialist fleet can serve multiple businesses with workspace-path + memory-key isolation. -- After ~5 consecutive spawn failures on the same task the dispatcher - auto-blocks it to prevent spin loops. +- After `kanban.failure_limit` consecutive non-success attempts on the + same task (default: 2), the dispatcher auto-blocks it to prevent spin + loops. Full user-facing docs: `website/docs/user-guide/features/kanban.md`. diff --git a/README.md b/README.md index abdc66245f..b659f56fa5 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri Run this in PowerShell: ```powershell -irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex +iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) ``` The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` โ€” no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands. diff --git a/acp_adapter/auth.py b/acp_adapter/auth.py index 7b2556fd06..b04a7b7b40 100644 --- a/acp_adapter/auth.py +++ b/acp_adapter/auth.py @@ -9,13 +9,24 @@ TERMINAL_SETUP_AUTH_METHOD_ID = "hermes-setup" def detect_provider() -> Optional[str]: - """Resolve the active Hermes runtime provider, or None if unavailable.""" + """Resolve the active Hermes runtime provider, or None if unavailable. + + Treats a ``Callable`` ``api_key`` (Azure Foundry Entra ID bearer + token provider โ€” see :mod:`agent.azure_identity_adapter`) as a valid + credential. Without this, ACP sessions for Entra-configured Foundry + deployments silently default to ``"openrouter"`` and the ACP auth + handshake rejects the legitimate provider. + """ try: from hermes_cli.runtime_provider import resolve_runtime_provider runtime = resolve_runtime_provider() api_key = runtime.get("api_key") provider = runtime.get("provider") - if isinstance(api_key, str) and api_key.strip() and isinstance(provider, str) and provider.strip(): + if not isinstance(provider, str) or not provider.strip(): + return None + is_string_key = isinstance(api_key, str) and api_key.strip() + is_callable_provider = callable(api_key) and not isinstance(api_key, str) + if is_string_key or is_callable_provider: return provider.strip().lower() except Exception: return None diff --git a/acp_adapter/edit_approval.py b/acp_adapter/edit_approval.py index 7c5fcaefd2..cbe7b699a5 100644 --- a/acp_adapter/edit_approval.py +++ b/acp_adapter/edit_approval.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio import json import logging +import tempfile from concurrent.futures import TimeoutError as FutureTimeout from contextvars import ContextVar, Token from dataclasses import dataclass @@ -158,8 +159,15 @@ def should_auto_approve_edit(proposal: EditProposal, policy: str, cwd: str | Non if policy == AUTO_APPROVE_SESSION: return True if policy == AUTO_APPROVE_WORKSPACE: - if str(path).startswith("/tmp/"): + # `/tmp` is the POSIX path but tempfile.gettempdir() is the real one on + # every platform: `/private/tmp` on macOS (because `/tmp` is a symlink + # and Path.resolve() follows it) and the per-user Temp dir on Windows. + tmp_root = Path(tempfile.gettempdir()).resolve(strict=False) + try: + path.relative_to(tmp_root) return True + except ValueError: + pass if cwd: root = Path(cwd).expanduser().resolve(strict=False) try: diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py index 76474e55da..29bd101edd 100644 --- a/acp_adapter/permissions.py +++ b/acp_adapter/permissions.py @@ -23,11 +23,21 @@ _OPTION_ID_TO_HERMES = { "allow_session": "session", "allow_always": "always", "deny": "deny", + "deny_always": "deny", } _PERMISSION_REQUEST_IDS = count(1) +def _permission_option_supports_kind(kind: str) -> bool: + """Return whether the installed ACP SDK accepts a permission option kind.""" + try: + PermissionOption(option_id="__probe__", kind=kind, name="probe") + except Exception: + return False + return True + + def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]: """Return ACP options that match Hermes approval semantics.""" options = [ @@ -49,6 +59,14 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption ), ) options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny")) + if _permission_option_supports_kind("reject_always"): + options.append( + PermissionOption( + option_id="deny_always", + kind="reject_always", + name="Deny always", + ), + ) return options @@ -62,12 +80,14 @@ def _build_permission_tool_call(command: str, description: str): import acp as _acp tool_call_id = f"perm-check-{next(_PERMISSION_REQUEST_IDS)}" + title = f"{description}: {command}" if description else command + content_text = f"{description}\n$ {command}" if description else f"$ {command}" return _acp.update_tool_call( tool_call_id, - title=description, + title=title, kind="execute", status="pending", - content=[_acp.tool_content(_acp.text_block(f"$ {command}"))], + content=[_acp.tool_content(_acp.text_block(content_text))], raw_input={"command": command, "description": description}, ) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index e4fc336b66..fbdee70527 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import datetime, timezone import base64 import contextvars import json @@ -46,6 +47,7 @@ from acp.schema import ( ResourceContentBlock, SessionCapabilities, SessionForkCapabilities, + SessionInfoUpdate, SessionListCapabilities, SessionMode, SessionModeState, @@ -707,6 +709,37 @@ class HermesACPAgent(acp.Agent): exc_info=True, ) + async def _send_session_info_update(self, session_id: str) -> None: + """Send ACP native session metadata after Hermes changes it.""" + if not self._conn: + return + try: + row = self.session_manager._get_db().get_session(session_id) + except Exception: + logger.debug("Could not read ACP session info for %s", session_id, exc_info=True) + return + if not row: + return + + title = row.get("title") + # The `sessions` table does not have an `updated_at` column (see + # hermes_state.py schema โ€” only started_at/ended_at). Use "now" as + # the updated_at since we're emitting this notification precisely + # because the title was just refreshed. + updated_at = datetime.now(timezone.utc).isoformat() + update = SessionInfoUpdate( + session_update="session_info_update", + title=title if isinstance(title, str) and title.strip() else None, + updated_at=updated_at, + ) + try: + await self._conn.session_update( + session_id=session_id, + update=update, + ) + except Exception: + logger.debug("Could not send ACP session info update for %s", session_id, exc_info=True) + def _schedule_usage_update(self, state: SessionState) -> None: """Schedule native context indicator refresh after ACP responses.""" if not self._conn: @@ -1374,9 +1407,10 @@ class HermesACPAgent(acp.Agent): previous_approval_cb = None previous_interactive = None edit_approval_token = None + previous_session_id = None def _run_agent() -> dict: - nonlocal previous_approval_cb, previous_interactive, edit_approval_token + nonlocal previous_approval_cb, previous_interactive, edit_approval_token, previous_session_id # Bind HERMES_SESSION_KEY for this session so per-session caches # (e.g. the interactive sudo password cache in tools.terminal_tool) # scope to the ACP session rather than leaking across sessions @@ -1411,6 +1445,13 @@ class HermesACPAgent(acp.Agent): # and the non-interactive auto-approve path must not fire. previous_interactive = os.environ.get("HERMES_INTERACTIVE") os.environ["HERMES_INTERACTIVE"] = "1" + # Propagate the originating ACP session id to tools that want to + # tag side-effects with it (e.g. ``kanban_create`` stamps it on + # the new task so clients can render a per-session board). Save + # and restore around the agent call so a re-used executor thread + # never leaks one session's id into the next session's tools. + previous_session_id = os.environ.get("HERMES_SESSION_ID") + os.environ["HERMES_SESSION_ID"] = session_id try: result = agent.run_conversation( user_message=user_content, @@ -1428,6 +1469,11 @@ class HermesACPAgent(acp.Agent): os.environ.pop("HERMES_INTERACTIVE", None) else: os.environ["HERMES_INTERACTIVE"] = previous_interactive + # Restore HERMES_SESSION_ID symmetrically. + if previous_session_id is None: + os.environ.pop("HERMES_SESSION_ID", None) + else: + os.environ["HERMES_SESSION_ID"] = previous_session_id if approval_cb: try: from tools import terminal_tool as _terminal_tool @@ -1471,12 +1517,20 @@ class HermesACPAgent(acp.Agent): try: from agent.title_generator import maybe_auto_title + def _notify_title_update(_title: str) -> None: + if conn: + loop.call_soon_threadsafe( + asyncio.create_task, + self._send_session_info_update(session_id), + ) + maybe_auto_title( self.session_manager._get_db(), session_id, user_text, final_response, state.history, + title_callback=_notify_title_update, ) except Exception: logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 6513f1bb55..be4e49d013 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -202,6 +202,44 @@ def _json_loads_maybe(value: Optional[str]) -> Any: return None +def _tool_result_failed(result: Optional[str], tool_name: str | None = None) -> bool: + """Return True when a structured Hermes tool result clearly failed. + + Keep this deliberately conservative. Plain text can contain words like + "error" because tests failed or a command printed diagnostics; Zed should + only receive ACP failed status for structured tool-level failures. + """ + # Raised exceptions from the agent's tool executor get wrapped in a + # canonical "Error executing tool '': ..." prefix (see + # agent/tool_executor.py around the try/except). That prefix is uniquely + # produced by the wrapper itself โ€” it cannot legitimately appear in + # well-behaved tool output. Catch it so a tool that blew up shows as + # failed in Zed instead of misleadingly green. + if isinstance(result, str) and result.startswith("Error executing tool '"): + return True + + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return False + + for key in ("success", "ok"): + if data.get(key) is False: + return True + + exit_code = data.get("exit_code", data.get("returncode")) + if isinstance(exit_code, int) and exit_code != 0: + return True + + # Hermes core/polished tools commonly report tool-level failures as a + # structured {"error": "..."} payload without an explicit success flag. + # Keep generic plugin/unknown tool payloads conservative to avoid marking + # optional diagnostic messages as failed. + if tool_name in _POLISHED_TOOLS and data.get("error") and not data.get("content"): + return True + + return False + + def _truncate_text(text: str, limit: int = 5000) -> str: if len(text) <= limit: return text @@ -278,6 +316,26 @@ def _format_search_files_result(result: Optional[str]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None + + files = data.get("files") + if isinstance(files, list): + total = data.get("total_count", len(files)) + shown = min(len(files), 20) + truncated = bool(data.get("truncated")) or len(files) > shown + lines = [ + "File search results", + f"Found {total} file{'s' if total != 1 else ''}; showing {shown}.", + "", + ] + for path in files[:shown]: + lines.append(f"- {path}") + if truncated: + lines.extend([ + "", + "Results truncated. Narrow the search, add path/file_glob, or use offset to page.", + ]) + return _truncate_text("\n".join(lines), limit=7000) + matches = data.get("matches") if not isinstance(matches, list): return None @@ -668,14 +726,114 @@ def _format_media_or_cron_result(tool_name: str, result: Optional[str]) -> Optio return "\n".join(lines) -def _format_generic_structured_result(tool_name: str, result: Optional[str]) -> Optional[str]: +def _format_structured_value( + key: str, + value: Any, + *, + indent: int = 0, + max_depth: int = 3, + max_items: int = 8, +) -> List[str]: + """Render nested JSON-ish values as compact Markdown bullets, not inline blobs.""" + prefix = " " * indent + bullet = f"{prefix}- " + label = f"**{key}:**" if key else "" + + if value in (None, "", [], {}): + return [] + + if max_depth <= 0: + if isinstance(value, (dict, list)): + preview = json.dumps(value, ensure_ascii=False, default=str) + else: + preview = str(value) + return [f"{bullet}{label} {_truncate_text(preview, limit=240)}" if label else f"{bullet}{_truncate_text(preview, limit=240)}"] + + if isinstance(value, dict): + lines = [f"{bullet}{label}" if label else f"{bullet}{len(value)} fields"] + shown = 0 + for child_key, child_value in value.items(): + if child_value in (None, "", [], {}): + continue + lines.extend( + _format_structured_value( + str(child_key), + child_value, + indent=indent + 1, + max_depth=max_depth - 1, + max_items=max_items, + ) + ) + shown += 1 + if shown >= max_items: + remaining = max(0, len(value) - shown) + if remaining: + lines.append(f"{' ' * (indent + 1)}- ... {remaining} more fields") + break + return lines + + if isinstance(value, list): + lines = [f"{bullet}{label} {len(value)} item{'s' if len(value) != 1 else ''}" if label else f"{bullet}{len(value)} item{'s' if len(value) != 1 else ''}"] + for idx, item in enumerate(value[:max_items], 1): + if isinstance(item, dict): + headline = str(item.get("content") or item.get("message") or item.get("title") or item.get("name") or item.get("id") or "").strip() + if headline: + lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(headline, limit=220)}") + for child_key in ("id", "status", "type", "scope", "quality_score", "score", "path", "url"): + child_value = item.get(child_key) + if child_value not in (None, "", [], {}): + lines.append(f"{' ' * (indent + 2)}- **{child_key}:** {_truncate_text(str(child_value), limit=180)}") + else: + lines.append(f"{' ' * (indent + 1)}{idx}.") + for child_key, child_value in list(item.items())[:max_items]: + lines.extend( + _format_structured_value( + str(child_key), + child_value, + indent=indent + 2, + max_depth=max_depth - 1, + max_items=max_items, + ) + ) + elif isinstance(item, list): + lines.append(f"{' ' * (indent + 1)}{idx}. {len(item)} items") + for nested in item[:max_items]: + lines.extend( + _format_structured_value( + "", + nested, + indent=indent + 2, + max_depth=max_depth - 1, + max_items=max_items, + ) + ) + else: + lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(str(item), limit=240)}") + if len(value) > max_items: + lines.append(f"{' ' * (indent + 1)}... {len(value) - max_items} more items") + return lines + + return [f"{bullet}{label} {_truncate_text(str(value), limit=500)}" if label else f"{bullet}{_truncate_text(str(value), limit=500)}"] + + +def _format_generic_structured_result( + tool_name: str, + result: Optional[str], + *, + fallback_to_text: bool = True, +) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, (dict, list)): - return result if isinstance(result, str) and result.strip() else None + return result if fallback_to_text and isinstance(result, str) and result.strip() else None if isinstance(data, list): lines = [f"{tool_name}: {len(data)} item{'s' if len(data) != 1 else ''}"] for item in data[:12]: - lines.append(f"- {_truncate_text(str(item), limit=240)}") + if isinstance(item, (dict, list)): + lines.extend(_format_structured_value("", item, indent=0, max_depth=2, max_items=6)) + else: + lines.append(f"- {_truncate_text(str(item), limit=240)}") + if len(data) > 12: + lines.append(f"... {len(data) - 12} more items") return _truncate_text("\n".join(lines), limit=5000) if data.get("success") is False or data.get("error"): @@ -699,12 +857,9 @@ def _format_generic_structured_result(tool_name: str, result: Optional[str]) -> continue if value in (None, "", [], {}): continue - if isinstance(value, (dict, list)): - preview = json.dumps(value, ensure_ascii=False, default=str) - else: - preview = str(value) - lines.append(f"- **{key}:** {_truncate_text(preview, limit=500)}") - if len(lines) >= 14: + lines.extend(_format_structured_value(str(key), value, indent=0, max_depth=3, max_items=8)) + if len(lines) >= 40: + lines.append("- ... more fields truncated") break content = data.get("content") @@ -744,8 +899,9 @@ def _build_polished_completion_content( if formatter is None and tool_name in _POLISHED_TOOLS: formatter = lambda: _format_generic_structured_result(tool_name, result) if formatter is None: - return None - text = formatter() + text = _format_generic_structured_result(tool_name, result, fallback_to_text=False) + else: + text = formatter() if not text: return None return [_text(text)] @@ -1135,6 +1291,11 @@ def build_tool_start( tool_call_id, title, kind=kind, content=content, locations=locations, ) + if not arguments: + return acp.start_tool_call( + tool_call_id, title, kind=kind, content=None, locations=locations, raw_input=None, + ) + # Generic fallback try: args_text = json.dumps(arguments, indent=2, default=str) @@ -1147,6 +1308,10 @@ def build_tool_start( ) +def _is_structured_json_result(result: Optional[str]) -> bool: + return isinstance(_json_loads_maybe(result), (dict, list)) + + def build_tool_complete( tool_call_id: str, tool_name: str, @@ -1169,9 +1334,9 @@ def build_tool_complete( return acp.update_tool_call( tool_call_id, kind=kind, - status="completed", + status="failed" if _tool_result_failed(result, tool_name) else "completed", content=content, - raw_output=None if tool_name in _POLISHED_TOOLS else result, + raw_output=None if tool_name in _POLISHED_TOOLS or _is_structured_json_result(result) else result, ) diff --git a/agent/agent_init.py b/agent/agent_init.py index df8fe229e7..e0846291ad 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -560,7 +560,16 @@ def init_agent( agent._client_kwargs = {} if not agent.quiet_mode: print(f"๐Ÿค– AI Agent initialized with model: {agent.model} (Anthropic native)") - if effective_key and len(effective_key) > 12: + # ``effective_key`` may be a callable Entra ID bearer + # provider for Azure Foundry anthropic_messages mode. + # The Anthropic adapter installs an httpx event hook + # that mints a fresh JWT per request โ€” we never + # invoke or inspect the callable in the banner. + from agent.azure_identity_adapter import is_token_provider + + if is_token_provider(effective_key): + print("๐Ÿ”‘ Using credentials: Microsoft Entra ID") + elif isinstance(effective_key, str) and len(effective_key) > 12: print(f"๐Ÿ”‘ Using token: {effective_key[:8]}...{effective_key[-4:]}") elif agent.api_mode == "bedrock_converse": # AWS Bedrock โ€” uses boto3 directly, no OpenAI client needed. @@ -764,12 +773,19 @@ def init_agent( print(f"๐Ÿค– AI Agent initialized with model: {agent.model}") if base_url: print(f"๐Ÿ”— Using custom base URL: {base_url}") - # Always show API key info (masked) for debugging auth issues + # ``api_key`` may be a callable Entra ID bearer + # provider (Azure Foundry). The OpenAI SDK mints a + # fresh JWT per request internally โ€” the banner + # never invokes or inspects the callable. + from agent.azure_identity_adapter import is_token_provider + key_used = client_kwargs.get("api_key", "none") - if key_used and key_used != "dummy-key" and len(key_used) > 12: + if is_token_provider(key_used): + print("๐Ÿ”‘ Using credentials: Microsoft Entra ID") + elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12: print(f"๐Ÿ”‘ Using API key: {key_used[:8]}...{key_used[-4:]}") else: - print(f"โš ๏ธ Warning: API key appears invalid or missing (got: '{key_used[:20] if key_used else 'none'}...')") + print("โš ๏ธ Warning: API key appears invalid or missing") except Exception as e: raise RuntimeError(f"Failed to initialize OpenAI client: {e}") @@ -812,7 +828,6 @@ def init_agent( tool_names = sorted(agent.valid_tool_names) if not agent.quiet_mode: print(f"๐Ÿ› ๏ธ Loaded {len(agent.tools)} tools: {', '.join(tool_names)}") - # Show filtering info if applied if enabled_toolsets: print(f" โœ… Enabled toolsets: {', '.join(enabled_toolsets)}") @@ -820,7 +835,18 @@ def init_agent( print(f" โŒ Disabled toolsets: {', '.join(disabled_toolsets)}") elif not agent.quiet_mode: print("๐Ÿ› ๏ธ No tools loaded (all tools filtered out or unavailable)") - + + # Kanban worker/orchestrator lifecycle guidance is session-static: + # the dispatcher decides at spawn time whether this process is a kanban + # worker (kanban_show tool is present iff HERMES_KANBAN_TASK is set). + # Resolving the ~835-token block once here avoids re-running the + # membership test + reference on every system-prompt rebuild + # (init + each context compression). + from agent.prompt_builder import KANBAN_GUIDANCE + agent._kanban_worker_guidance = ( + KANBAN_GUIDANCE if "kanban_show" in agent.valid_tool_names else "" + ) + # Check tool requirements if agent.tools and not agent.quiet_mode: requirements = _ra().check_toolset_requirements() @@ -1089,6 +1115,9 @@ def init_agent( compression_protect_first = max( 0, int(_compression_cfg.get("protect_first_n", 3)) ) + compression_abort_on_summary_failure = str( + _compression_cfg.get("abort_on_summary_failure", False) + ).lower() in {"true", "1", "yes"} # Read optional explicit context_length override for the auxiliary # compression model. Custom endpoints often cannot report this via @@ -1303,6 +1332,7 @@ def init_agent( config_context_length=_config_context_length, provider=agent.provider, api_mode=agent.api_mode, + abort_on_summary_failure=compression_abort_on_summary_failure, ) agent.compression_enabled = compression_enabled @@ -1395,7 +1425,12 @@ def init_agent( _ra().logger.debug("Invalid ollama_num_ctx config value: %r", _ollama_num_ctx_override) if agent._ollama_num_ctx is None and agent.base_url and is_local_endpoint(agent.base_url): try: - _detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=agent.api_key or "") + # ``agent.api_key`` may be a callable (Entra token provider). + # Ollama detection makes a manual HTTP request and expects a + # string โ€” Azure Foundry isn't a local endpoint so this branch + # never fires for Entra, but guard defensively. + _key_for_ollama = agent.api_key if isinstance(agent.api_key, str) else "" + _detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=_key_for_ollama or "") if _detected and _detected > 0: agent._ollama_num_ctx = _detected except Exception as exc: @@ -1431,7 +1466,13 @@ def init_agent( # Gateway status_callback is not yet wired, so any warning is stored # in _compression_warning and replayed in the first run_conversation(). agent._compression_warning = None - agent._check_compression_model_feasibility() + # Lazy feasibility check: deferred to the first turn that approaches the + # compression threshold. Running it eagerly here costs ~400ms cold (network + # probe of the auxiliary provider chain + /models lookup) on every agent + # init, including short ``chat -q`` runs that never reach the threshold. + # ``ensure_compression_feasibility_checked`` (called from + # ``run_conversation``'s preflight) runs it at most once per agent. + agent._compression_feasibility_checked = False # Snapshot primary runtime for per-turn restoration. When fallback # activates during a turn, the next turn restores these values so the diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 61551a65dc..7a9a0961a7 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -39,7 +39,7 @@ from agent.message_sanitization import ( _repair_tool_call_arguments, _sanitize_surrogates, ) -from agent.tool_dispatch_helpers import _trajectory_normalize_msg +from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message from agent.trajectory import convert_scratchpad_to_think from agent.error_classifier import classify_api_error, FailoverReason from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write @@ -317,12 +317,11 @@ def sanitize_tool_call_arguments( if existing_tool_msg is None: messages.insert( insert_at, - { - "role": "tool", - "name": function_name if function_name != "?" else "", - "tool_call_id": tool_call_id, - "content": marker, - }, + make_tool_result_message( + function_name if function_name != "?" else "", + marker, + tool_call_id, + ), ) insert_at += 1 else: @@ -606,7 +605,22 @@ def recover_with_credential_pool( return False, True if effective_reason == FailoverReason.auth: - if agent._is_entitlement_failure(error_context, status_code): + # Subscription/entitlement 403s look like auth failures on the wire + # but refresh cannot fix them โ€” the OAuth token is already valid, + # the account simply lacks the entitlement. Without this guard, + # ``try_refresh_current()`` keeps minting fresh tokens against the + # same unsubscribed account and the main agent loop spins re-issuing + # the same 403 until the user Ctrl+C's. + # + # Defense-in-depth for #26847: xAI's backend has been seen to 403 + # standard SuperGrok subscribers with bodies that don't match the + # existing entitlement keyword set in ``_is_entitlement_failure``. + # Any 403 against ``xai-oauth`` is treated as entitlement here so + # the refresh loop can't spin in those cases either. + is_entitlement = agent._is_entitlement_failure(error_context, status_code) + if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth": + is_entitlement = True + if is_entitlement: _ra().logger.info( "Credential %s โ€” entitlement-shaped 403 from %s; " "skipping pool refresh (account lacks subscription, " @@ -1390,10 +1404,16 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo _sm_custom_providers = get_compatible_custom_providers(_sm_cfg) except Exception: _sm_custom_providers = None + # ``agent.api_key`` may be a callable (Azure Foundry Entra ID + # token provider). ``get_model_context_length`` expects a + # string for its live-probe paths; for Foundry the context + # length normally resolves via config or static catalogs and + # never hits a probe, but coerce to empty string defensively. + _ctx_api_key = agent.api_key if isinstance(agent.api_key, str) else "" new_context_length = get_model_context_length( agent.model, base_url=agent.base_url, - api_key=agent.api_key, + api_key=_ctx_api_key, provider=agent.provider, config_context_length=getattr(agent, "_config_context_length", None), custom_providers=_sm_custom_providers, @@ -1402,7 +1422,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo model=agent.model, context_length=new_context_length, base_url=agent.base_url, - api_key=getattr(agent, "api_key", ""), + api_key=agent.api_key, # context_compressor forwards to call_llm; callable preserved provider=agent.provider, api_mode=agent.api_mode, ) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index de9b7dd586..c94d664a43 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -17,6 +17,7 @@ import os import platform import subprocess from pathlib import Path +from urllib.parse import urlparse from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple @@ -364,7 +365,7 @@ def _normalize_base_url_text(base_url) -> str: def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool: """Return True for non-Anthropic endpoints using the Anthropic Messages API. - Third-party proxies (Azure AI Foundry, AWS Bedrock, self-hosted) authenticate + Third-party proxies (Microsoft Foundry, AWS Bedrock, self-hosted) authenticate with their own API keys via x-api-key, not Anthropic OAuth tokens. OAuth detection should be skipped for these endpoints. """ @@ -508,6 +509,29 @@ def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool: ) +def _is_azure_anthropic_endpoint(base_url: str | None) -> bool: + """Return True for Azure-hosted Anthropic Messages endpoints. + + Covers both the modern Foundry host family (``*.services.ai.azure.*``) + and the legacy Azure OpenAI host family (``*.openai.azure.*``) when + serving Anthropic's ``/anthropic`` route. Used to opt-in those hosts + to the ``api-version`` query-param plumbing required by Azure. + + Intentionally avoids a finite allow-list of TLD suffixes so it works + across sovereign / private Azure clouds. + """ + normalized = _normalize_base_url_text(base_url) + if not normalized: + return False + parsed = urlparse(normalized) + host = (parsed.hostname or "").lower().rstrip(".") + path = (parsed.path or "").lower() + host_padded = f".{host}." + is_foundry_host = ".services.ai.azure." in host_padded + is_legacy_azoai_host = ".openai.azure." in host_padded + return (is_foundry_host or is_legacy_azoai_host) and "/anthropic" in path + + def _common_betas_for_base_url( base_url: str | None, *, @@ -523,7 +547,7 @@ def _common_betas_for_base_url( The ``context-1m-2025-08-07`` beta is not sent to native Anthropic by default because some subscriptions reject it. Add it only for endpoint - families that still require it for 1M context, currently Azure AI Foundry. + families that still require it for 1M context, currently Microsoft Foundry. Bedrock uses its own client helper below and opts in explicitly. ``drop_context_1m_beta=True`` strips the 1M-context beta from any path that @@ -540,8 +564,81 @@ def _common_betas_for_base_url( return betas +def _build_anthropic_client_with_bearer_hook( + token_provider, + base_url: str = None, + timeout: float = None, + *, + drop_context_1m_beta: bool = False, +): + """Anthropic-on-Foundry Entra ID variant of :func:`build_anthropic_client`. + + Anthropic SDK 0.86.0 stores ``api_key`` / ``auth_token`` as static + strings; there is no callable-token contract. To get per-request + bearer refresh (Microsoft's documented Foundry pattern), we hand + the SDK a custom ``httpx.Client`` whose request event hook mints a + fresh JWT from the Entra credential chain and rewrites + ``Authorization: Bearer `` on every outbound request. The SDK + ignores its own auth logic when ``http_client`` is provided (the + hook strips any pre-set Authorization). + + The placeholder ``auth_token`` is required because the SDK raises + ``AnthropicError`` at construction if neither ``api_key`` nor + ``auth_token`` is set โ€” but the hook overrides it per-request so + the placeholder value never reaches Azure. + """ + _anthropic_sdk = _get_anthropic_sdk() + if _anthropic_sdk is None: + raise ImportError( + "The 'anthropic' package is required for Azure Foundry Anthropic-style " + "endpoints with Entra ID auth. Install with: pip install 'anthropic>=0.39.0'" + ) + + normalize_proxy_env_vars() + + from httpx import Timeout + from agent.azure_identity_adapter import build_bearer_http_client + + _read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0 + timeout_obj = Timeout(timeout=float(_read_timeout), connect=10.0) + + # Strip any trailing /v1 โ€” the Anthropic SDK appends /v1/messages. + normalized_base_url = _normalize_base_url_text(base_url) + if normalized_base_url: + import re as _re + normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/")) + + http_client = build_bearer_http_client(token_provider, timeout=timeout_obj) + + kwargs = { + "timeout": timeout_obj, + "http_client": http_client, + # The SDK requires *something* for api_key/auth_token. Our + # event hook overrides Authorization per request so this value + # is never sent. The sentinel string makes accidental leaks + # diagnosable in logs. + "auth_token": "entra-id-bearer-via-http-hook", + } + + if normalized_base_url: + if _is_azure_anthropic_endpoint(normalized_base_url) and "api-version" not in normalized_base_url: + kwargs["base_url"] = normalized_base_url + kwargs["default_query"] = {"api-version": "2025-04-15"} + else: + kwargs["base_url"] = normalized_base_url + + common_betas = _common_betas_for_base_url( + normalized_base_url, + drop_context_1m_beta=drop_context_1m_beta, + ) + if common_betas: + kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} + + return _anthropic_sdk.Anthropic(**kwargs) + + def build_anthropic_client( - api_key: str, + api_key, base_url: str = None, timeout: float = None, *, @@ -549,6 +646,17 @@ def build_anthropic_client( ): """Create an Anthropic client, auto-detecting setup-tokens vs API keys. + ``api_key`` accepts either: + + * a static ``str`` โ€” the historical contract for all key-based and + OAuth flows. + * a ``Callable[[], str]`` โ€” an Entra ID bearer token provider from + :mod:`agent.azure_identity_adapter`. The Anthropic SDK itself + requires a static string, so when given a callable we construct + a custom ``httpx.Client`` with a request event hook that mints a + fresh JWT per outbound request and rewrites the ``Authorization`` + header. The SDK never sees the callable directly. + If *timeout* is provided it overrides the default 900s read timeout. The connect timeout stays at 10s. Callers pass this from the per-provider / per-model ``request_timeout_seconds`` config so Anthropic-native and @@ -570,6 +678,14 @@ def build_anthropic_client( "Install it with: pip install 'anthropic>=0.39.0'" ) + # Callable api_key โ†’ Entra ID bearer provider path. Delegated to a + # helper so the existing static-key code below stays unchanged. + if callable(api_key) and not isinstance(api_key, str): + return _build_anthropic_client_with_bearer_hook( + api_key, base_url, timeout, + drop_context_1m_beta=drop_context_1m_beta, + ) + normalize_proxy_env_vars() from httpx import Timeout @@ -584,8 +700,7 @@ def build_anthropic_client( # Pass it via default_query so the SDK appends it to every request URL # without corrupting the base_url (appending it directly produces # malformed paths like /anthropic?api-version=.../v1/messages). - _is_azure_endpoint = "azure.com" in normalized_base_url.lower() - if _is_azure_endpoint and "api-version" not in normalized_base_url: + if _is_azure_anthropic_endpoint(normalized_base_url) and "api-version" not in normalized_base_url: kwargs["base_url"] = normalized_base_url.rstrip("/") kwargs["default_query"] = {"api-version": "2025-04-15"} else: @@ -615,7 +730,7 @@ def build_anthropic_client( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} elif _is_third_party_anthropic_endpoint(base_url): - # Third-party proxies (Azure AI Foundry, AWS Bedrock, etc.) use their + # Third-party proxies (Microsoft Foundry, AWS Bedrock, etc.) use their # own API keys with x-api-key auth. Skip OAuth detection โ€” their keys # don't follow Anthropic's sk-ant-* prefix convention and would be # misclassified as OAuth tokens. @@ -1757,7 +1872,7 @@ def convert_messages_to_anthropic( # causing HTTP 400 "Invalid signature in thinking block". # # Signatures are Anthropic-proprietary. Third-party endpoints - # (MiniMax, Azure AI Foundry, self-hosted proxies) cannot validate + # (MiniMax, Microsoft Foundry, self-hosted proxies) cannot validate # them and will reject them outright. When targeting a third-party # endpoint, strip ALL thinking/redacted_thinking blocks from every # assistant message โ€” the third-party will generate its own @@ -2103,5 +2218,3 @@ def build_anthropic_kwargs( kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)} return kwargs - - diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 5d44fe1086..89dc7d935b 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -711,8 +711,12 @@ class _CodexCompletionsAdapter: # keywords (HTTP 400). Strip them here to match the parity guarantee that # chat_completion_helpers.py provides for the main-agent xAI path. try: - from tools.schema_sanitizer import strip_pattern_and_format + from tools.schema_sanitizer import ( + strip_pattern_and_format, + strip_slash_enum, + ) tools, _ = strip_pattern_and_format(list(tools)) + tools, _ = strip_slash_enum(tools) except Exception as exc: logger.warning( "Auxiliary client: failed to sanitize tool schemas for " @@ -1303,7 +1307,10 @@ def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]: with xAI Grok OAuth. """ try: - from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL + from hermes_cli.auth import ( + DEFAULT_XAI_OAUTH_BASE_URL, + _xai_validate_inference_base_url, + ) pool = load_pool("xai-oauth") if pool and pool.has_credentials(): @@ -1314,13 +1321,13 @@ def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]: or getattr(entry, "access_token", "") or "" ).strip() - base_url = str( + base_url = _xai_validate_inference_base_url( os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") or os.getenv("XAI_BASE_URL", "").strip().rstrip("/") - or getattr(entry, "runtime_base_url", None) - or getattr(entry, "base_url", None) - or DEFAULT_XAI_OAUTH_BASE_URL - ).strip().rstrip("/") + or str(getattr(entry, "runtime_base_url", None) or "").strip().rstrip("/") + or str(getattr(entry, "base_url", None) or "").strip().rstrip("/"), + fallback=DEFAULT_XAI_OAUTH_BASE_URL, + ) if api_key and base_url: return api_key, base_url except Exception as exc: @@ -1902,6 +1909,120 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]: return CodexAuxiliaryClient(real_client, model), model +def _try_azure_foundry( + *, + model: Optional[str] = None, + explicit_api_key: Optional[str] = None, + explicit_base_url: Optional[str] = None, + api_mode: Optional[str] = None, +) -> Tuple[Optional[Any], Optional[str]]: + """Resolve an Azure Foundry auxiliary client via the runtime resolver. + + Mirrors the ``_try_anthropic`` / ``_try_nous`` shape but delegates to + :func:`hermes_cli.runtime_provider._resolve_azure_foundry_runtime` โ€” + the same resolver the main agent uses โ€” so: + + * ``auth_mode: api_key`` (default) gets the static + ``AZURE_FOUNDRY_API_KEY`` string. + * ``auth_mode: entra_id`` gets a callable bearer-token provider + (``Callable[[], str]`` from + :mod:`agent.azure_identity_adapter`). + * Per-model ``api_mode`` auto-routing for GPT-5.x / o-series / + codex models works. + * ``model.entra.{tenant_id,client_id,authority,scope}`` config + fields propagate. + * Non-default ``model.base_url`` overrides are honored. + + The OpenAI SDK accepts both shapes for ``api_key`` so the caller + can forward the result without coercion. + + Returns ``(client, model)`` or ``(None, None)`` on failure. + """ + try: + from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime + from hermes_cli.auth import AuthError + from hermes_cli.config import load_config + except ImportError: + return None, None + + try: + cfg = load_config() + model_cfg = cfg.get("model") if isinstance(cfg, dict) else {} + if not isinstance(model_cfg, dict): + model_cfg = {} + except Exception: + model_cfg = {} + + try: + runtime = _resolve_azure_foundry_runtime( + requested_provider="azure-foundry", + model_cfg=model_cfg, + explicit_api_key=explicit_api_key, + explicit_base_url=explicit_base_url, + target_model=model, + ) + except AuthError as exc: + logger.debug("Auxiliary azure-foundry: %s", exc) + return None, None + except Exception as exc: + logger.debug("Auxiliary azure-foundry runtime error: %s", exc) + return None, None + + api_key = runtime.get("api_key") + base_url = str(runtime.get("base_url", "") or "") + runtime_api_mode = api_mode or runtime.get("api_mode") or "chat_completions" + + # Empty-string check on api_key here would be wrong for callable + # token providers (callables are truthy and non-empty by definition). + # Bail only when api_key is None / empty string. + _has_key = bool(api_key) if not callable(api_key) else True + if not _has_key or not base_url: + return None, None + + final_model = _normalize_resolved_model( + model or str(model_cfg.get("default") or ""), + "azure-foundry", + ) + if not final_model: + # No fallback aux model for Azure โ€” the user must have a + # deployment name. Surface that as "no client" so the auto + # chain falls through to the next provider rather than 404ing. + logger.debug( + "Auxiliary azure-foundry: no model resolved (model=%r, default=%r)", + model, model_cfg.get("default"), + ) + return None, None + + # Azure pre-v1 endpoints sometimes carry api-version query params + # in the base URL; the OpenAI SDK drops them when joining paths, + # so lift them out and pass via default_query. + extra: Dict[str, Any] = {} + _clean_base, _dq = _extract_url_query_params(base_url) + if _dq: + extra["default_query"] = _dq + + client = OpenAI(api_key=api_key, base_url=_clean_base, **extra) + + if runtime_api_mode == "codex_responses": + # GPT-5.x / o-series / codex models on Azure Foundry are + # Responses-API-only โ€” wrap so chat.completions.create() is + # translated to /responses behind the scenes. + return CodexAuxiliaryClient(client, final_model), final_model + + if runtime_api_mode == "anthropic_messages": + # Forward ``api_key`` verbatim โ€” for static keys it's a string, + # for Entra ID it's a callable. ``_maybe_wrap_anthropic`` โ†’ + # ``build_anthropic_client`` detects the callable and installs + # the bearer-injecting httpx hook. + return _maybe_wrap_anthropic( + client, final_model, api_key, + base_url, runtime_api_mode, + ), final_model + + # chat_completions โ€” return the plain OpenAI client. + return client, final_model + + def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]: try: from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token @@ -1957,20 +2078,31 @@ _AUTO_PROVIDER_LABELS = { "_resolve_api_key_provider": "api-key", } -_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode") +_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode") -def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, str]: - """Return a sanitized copy of a live main-runtime override.""" +def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Return a sanitized copy of a live main-runtime override. + + Most fields are stripped strings. ``api_key`` may legitimately be a + zero-arg callable (Azure Foundry Entra ID token provider) โ€” preserve + those as-is so auxiliary clients inherit the same authentication + surface as the main agent. The OpenAI SDK accepts ``Callable[[], str]`` + for ``api_key`` and calls it before every request. + """ if not isinstance(main_runtime, dict): return {} - normalized: Dict[str, str] = {} + normalized: Dict[str, Any] = {} for field in _MAIN_RUNTIME_FIELDS: value = main_runtime.get(field) + # Preserve a callable api_key (Entra ID bearer provider) unchanged. + if field == "api_key" and callable(value) and not isinstance(value, str): + normalized[field] = value + continue if isinstance(value, str) and value.strip(): normalized[field] = value.strip() provider = normalized.get("provider") - if provider: + if isinstance(provider, str): normalized["provider"] = provider.lower() return normalized @@ -2762,10 +2894,10 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option auxiliary_is_nous = False # Reset โ€” _try_nous() will set True if it wins runtime = _normalize_main_runtime(main_runtime) runtime_provider = runtime.get("provider", "") - runtime_model = runtime.get("model", "") - runtime_base_url = runtime.get("base_url", "") + runtime_model = str(runtime.get("model") or "") + runtime_base_url = str(runtime.get("base_url") or "") runtime_api_key = runtime.get("api_key", "") - runtime_api_mode = runtime.get("api_mode", "") + runtime_api_mode = str(runtime.get("api_mode") or "") # โ”€โ”€ Warn once if OPENAI_BASE_URL is set but config.yaml uses a named # provider (not 'custom'). This catches the common "env poisoning" @@ -2793,8 +2925,8 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option # on aggregators (OpenRouter, Nous) who previously got routed to a # cheap provider-side default. Explicit per-task overrides set via # config.yaml (auxiliary..provider) still win over this. - main_provider = runtime_provider or _read_main_provider() - main_model = runtime_model or _read_main_model() + main_provider = str(runtime_provider or _read_main_provider() or "") + main_model = str(runtime_model or _read_main_model() or "") if (main_provider and main_model and main_provider not in {"auto", ""}): resolved_provider = main_provider @@ -3188,7 +3320,11 @@ def resolve_provider_client( if client is not None: final_model = _normalize_resolved_model(model or default, provider) _cbase = str(getattr(client, "base_url", "") or "") - _ckey = str(getattr(client, "api_key", "") or "") + # ``client.api_key`` may be a callable (Azure Foundry Entra + # bearer provider). Pass empty string for the wrapper-detection + # path โ€” wrapping decisions are based on base_url + api_mode. + _raw_ckey = getattr(client, "api_key", "") + _ckey = "" if (callable(_raw_ckey) and not isinstance(_raw_ckey, str)) else str(_raw_ckey or "") client = _wrap_if_needed(client, final_model, _cbase, _ckey) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) @@ -3300,6 +3436,40 @@ def resolve_provider_client( except ImportError: pass + # โ”€โ”€ Azure Foundry (delegates to runtime resolver for auth_mode-aware routing) โ”€ + # + # The generic PROVIDER_REGISTRY path below uses + # ``resolve_api_key_provider_credentials`` which only knows about the + # static ``AZURE_FOUNDRY_API_KEY`` env var. That misses two important + # cases for the ``azure-foundry`` provider: + # + # 1. ``model.auth_mode: entra_id`` โ€” no static key exists; we need + # a callable bearer-token provider from ``azure_identity_adapter``. + # 2. Non-default ``model.base_url`` (Foundry projects path) โ€” the + # env-var-only resolver doesn't apply config-yaml-driven URL + # overrides. + # + # Delegate to the same runtime resolver the main agent uses so + # auxiliary tasks (title generation, compression, vision, embedding, + # session search) inherit the user's full Azure config. + if provider == "azure-foundry": + client, default_model = _try_azure_foundry( + model=model, + explicit_api_key=explicit_api_key, + explicit_base_url=explicit_base_url, + api_mode=api_mode, + ) + if client is None: + logger.warning( + "resolve_provider_client: azure-foundry requested but " + "runtime resolution failed (run: hermes doctor for " + "diagnostics)" + ) + return None, None + final_model = _normalize_resolved_model(model or default_model, provider) + return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode + else (client, final_model)) + # โ”€โ”€ API-key providers from PROVIDER_REGISTRY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ try: from hermes_cli.auth import ( diff --git a/agent/azure_identity_adapter.py b/agent/azure_identity_adapter.py new file mode 100644 index 0000000000..9506715019 --- /dev/null +++ b/agent/azure_identity_adapter.py @@ -0,0 +1,555 @@ +"""Microsoft Entra ID adapter for Microsoft Foundry. + +Provides keyless authentication for Microsoft Foundry deployments using the +`azure-identity` SDK's `DefaultAzureCredential` chain (env service principal +โ†’ workload identity โ†’ managed identity โ†’ VS Code โ†’ Azure CLI โ†’ azd โ†’ +PowerShell โ†’ broker). + +Architecture mirrors `agent/bedrock_adapter.py`: + +* Lazy import. `azure-identity` is only loaded when ``model.auth_mode = + entra_id`` is selected. Users who stick with `AZURE_FOUNDRY_API_KEY` + never pay the import cost. +* SDK-callable contract. The public entry point ``build_token_provider`` + returns a zero-arg callable produced by ``get_bearer_token_provider`` โ€” + this is exactly the value Microsoft's documented sample plugs into + ``OpenAI(api_key=token_provider, base_url=...)``. The OpenAI SDK calls + it before every request, so token refresh is transparent. +* Three explicit consumer-side helpers (display / cache / http-bearer) + rather than one generic "materialize" function โ€” splitting them by + purpose prevents accidental token-minting in logging paths or token + leakage into cache keys / dashboard JSON. +* No persisted JWT. ``azure-identity`` caches in-process and (where + available) in the OS keychain or ``~/.IdentityService``. Hermes does + not duplicate that storage in ``auth.json``. + +Reference: https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id + +Requires: ``azure-identity`` (optional dependency โ€” only needed when +``model.auth_mode = entra_id``). +""" + +from __future__ import annotations + +import functools +import logging +import os +import threading +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional + +logger = logging.getLogger(__name__) + +# Microsoft-documented scope for Foundry inference auth. Both the new +# Foundry portal and the legacy Azure OpenAI managed-identity docs use +# this scope for ALL Foundry endpoint shapes (*.openai.azure.com, +# *.services.ai.azure.com, *.ai.azure.com). The older control-plane +# scope ``https://cognitiveservices.azure.com/.default`` is for ARM +# resource management and is rejected for inference by newer +# resources โ€” users with that requirement override via +# ``model.entra.scope`` in config.yaml. +SCOPE_AI_AZURE_DEFAULT = "https://ai.azure.com/.default" + +# --------------------------------------------------------------------------- +# Lazy SDK import โ€” only loaded when the Entra path is actually used. +# --------------------------------------------------------------------------- + +_AZURE_IDENTITY_FEATURE = "provider.azure_identity" + + +def has_azure_identity_installed() -> bool: + """Return True if `azure-identity` can be imported right now. + + Cheap check โ€” does not walk the credential chain. + """ + try: + import azure.identity # noqa: F401 + return True + except Exception: + return False + + +def _require_azure_identity(): + """Import ``azure.identity``, lazy-installing it if allowed. + + Raises ``ImportError`` with a clear actionable message when the + package is missing and lazy installs are disabled. + """ + try: + import azure.identity as _ai + return _ai + except ImportError: + try: + from tools.lazy_deps import ensure, FeatureUnavailable + except ImportError as exc: + raise ImportError( + "The 'azure-identity' package is required for Azure AI " + "Foundry Entra ID authentication. Install it with: " + "pip install azure-identity" + ) from exc + + try: + ensure(_AZURE_IDENTITY_FEATURE, prompt=False) + except FeatureUnavailable as exc: + raise ImportError( + "The 'azure-identity' package is required for Azure AI " + "Foundry Entra ID authentication. " + str(exc) + ) from exc + + # Retry import after lazy install. + import azure.identity as _ai # noqa: WPS440 + return _ai + + +def reset_credential_cache() -> None: + """Clear the cached ``DefaultAzureCredential``. Used by tests and + profile switches. + + Defensive against tests that ``monkeypatch.setattr`` over + ``build_credential`` with a plain (non-lru-cached) function โ€” those + won't expose ``cache_clear()`` until pytest reverts the patch. + """ + cache_clear = getattr(build_credential, "cache_clear", None) + if callable(cache_clear): + cache_clear() + + +# --------------------------------------------------------------------------- +# Token-provider construction +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class EntraIdentityConfig: + """Serializable Entra ID config. + + Captures the Hermes-managed Entra knobs we need outside Azure SDK + environment configuration. Everything else + (tenant ID, service principal secret, federated token file, sovereign + cloud authority, etc.) flows through azure-identity's standard + ``AZURE_*`` env vars โ€” see the Bedrock pattern in + ``hermes_cli/runtime_provider.py:1310-1377`` for the analogous + "let the SDK read env" approach. + + ``scope`` is Microsoft's documented Foundry inference audience. Almost + everyone uses the default; sovereign-cloud / non-standard tenants can + override via ``model.entra.scope``. Identity selection (user-assigned + managed identity, workload identity, service principal, tenant, authority) + stays in the standard Azure SDK env vars such as ``AZURE_CLIENT_ID``. + + ``exclude_interactive_browser`` is kept as an internal constructor knob + so probes stay non-interactive by default. It is not written by the setup + wizard. + + The dataclass is frozen so it's hashable for ``functools.lru_cache`` + keying, and serializable across multiprocessing boundaries (workers + rebuild the credential inside their own process). + """ + + scope: str = SCOPE_AI_AZURE_DEFAULT + exclude_interactive_browser: bool = True + + def __post_init__(self) -> None: + scope = str(self.scope or "").strip() or SCOPE_AI_AZURE_DEFAULT + object.__setattr__(self, "scope", scope) + + def to_dict(self) -> Dict[str, Any]: + return { + "scope": self.scope, + "exclude_interactive_browser": self.exclude_interactive_browser, + } + + @classmethod + def from_dict(cls, data: Optional[Dict[str, Any]], + *, default_scope: Optional[str] = None) -> "EntraIdentityConfig": + data = data or {} + scope = str(data.get("scope") or "").strip() or default_scope or SCOPE_AI_AZURE_DEFAULT + exclude_browser = bool(data.get("exclude_interactive_browser", True)) + return cls( + scope=scope, + exclude_interactive_browser=exclude_browser, + ) + + +def _build_default_credential(config: EntraIdentityConfig) -> Any: + """Construct a ``DefaultAzureCredential`` for ``config``. + + Only Hermes-selected knobs are passed as kwargs. Everything else + (tenant, service principal secret, federated token file, sovereign + cloud authority, etc.) is read by ``azure-identity`` from the + standard ``AZURE_*`` environment variables โ€” see Microsoft's + documented credential resolution chain. Users configure those in + ``~/.hermes/.env`` or the deployment environment. + """ + ai = _require_azure_identity() + kwargs: Dict[str, Any] = {} + # SDK default is True (browser excluded); only pass when the user + # explicitly opts in to interactive browser auth. + if not config.exclude_interactive_browser: + kwargs["exclude_interactive_browser_credential"] = False + return ai.DefaultAzureCredential(**kwargs) + + +@functools.lru_cache(maxsize=1) +def build_credential(config: EntraIdentityConfig) -> Any: + """Return the cached ``DefaultAzureCredential`` for ``config``. + + Hermes processes use exactly one Entra config at a time (the + ``model.entra.*`` block in config.yaml drives every aux task, + subagent, and credential probe in the session). ``maxsize=1`` is + intentional: it reflects the actual usage pattern and keeps the + cache trivially small. + + ``EntraIdentityConfig`` is a frozen dataclass, so it's hashable and + safe as an LRU-cache key. ``functools.lru_cache`` is thread-safe in + CPython. + + If two distinct configs are ever passed (tests do this; production + rarely), the LRU eviction handles it correctly โ€” each call still + returns a credential matching its config; only one is cached at a + time. Use :func:`reset_credential_cache` to clear (e.g. in tests). + """ + return _build_default_credential(config) + + +def build_token_provider(scope: Optional[str] = None, + *, + config: Optional[EntraIdentityConfig] = None, + base_url: Optional[str] = None, + exclude_interactive_browser: bool = True, + ) -> Callable[[], str]: + """Return a zero-arg callable that mints a fresh Entra bearer JWT. + + The returned callable is exactly what Microsoft's documented Foundry + sample expects:: + + from openai import OpenAI + client = OpenAI( + base_url="https://my-resource.openai.azure.com/openai/v1/", + api_key=build_token_provider(), + ) + + Scope resolution order: + 1. ``config.scope`` when a config object is supplied + 2. explicit ``scope`` kwarg + 3. ``SCOPE_AI_AZURE_DEFAULT`` (Microsoft's documented Foundry scope) + + ``base_url`` is unused today and kept for back-compat. Tenant / + service-principal / sovereign-cloud configuration flows through + ``azure-identity``'s standard ``AZURE_*`` environment variables โ€” + see :func:`_build_default_credential` for the rationale. + + NOT serializable across process boundaries. For multiprocessing + workers, serialize the ``EntraIdentityConfig`` and rebuild the + provider inside the worker. + """ + ai = _require_azure_identity() + if config is None: + config = EntraIdentityConfig( + scope=scope or SCOPE_AI_AZURE_DEFAULT, + exclude_interactive_browser=exclude_interactive_browser, + ) + credential = build_credential(config) + return ai.get_bearer_token_provider(credential, config.scope) + + +# --------------------------------------------------------------------------- +# Credential probing +# --------------------------------------------------------------------------- + + +def has_azure_identity_credentials(scope: Optional[str] = None, + *, + config: Optional[EntraIdentityConfig] = None, + timeout_seconds: float = 10.0, + allow_install: bool = True, + **overrides: Any) -> bool: + """Best-effort probe: can `DefaultAzureCredential` mint a token now? + + Runs ``credential.get_token(scope)`` under a thread-based timeout so + a slow token service can't hang the caller. Returns False on any + error โ€” never raises. Use for ``hermes doctor`` / + ``hermes auth status`` / wizard preflight. + + ``allow_install``: when True (default) and ``azure-identity`` is not + importable, the adapter triggers the standard lazy-install path + (subject to ``security.allow_lazy_installs``) before probing. Set + False to make this strictly an "is installed?" check โ€” used on hot + paths like CLI startup where we never want pip to run. + + NOT used by ``is_provider_configured()`` โ€” that path is structural + only (no token mint), so CLI startup doesn't pay this latency. + """ + if not has_azure_identity_installed(): + if not allow_install: + return False + try: + _require_azure_identity() + except ImportError as exc: + logger.debug("azure-identity lazy install unavailable: %s", exc) + return False + if config is None: + effective_scope = (scope or "").strip() or SCOPE_AI_AZURE_DEFAULT + config = EntraIdentityConfig(scope=effective_scope, **overrides) + + result = {"ok": False} + + def _probe() -> None: + try: + credential = build_credential(config) + tok = credential.get_token(config.scope) + result["ok"] = bool(getattr(tok, "token", None)) + except Exception as exc: + logger.debug("Entra credential probe failed: %s", exc) + result["ok"] = False + + thread = threading.Thread(target=_probe, daemon=True) + thread.start() + thread.join(timeout=max(0.01, timeout_seconds)) + if thread.is_alive(): + logger.debug("Entra token service probe timed out after %ss", timeout_seconds) + return False + return bool(result.get("ok")) + + +def describe_active_credential(config: Optional[EntraIdentityConfig] = None, + *, + scope: Optional[str] = None, + timeout_seconds: float = 10.0, + allow_install: bool = True, + **overrides: Any) -> Dict[str, Any]: + """Return diagnostic info about the active credential chain. + + Best-effort: runs ``get_token()`` and inspects what came back. + Designed for ``hermes doctor`` and the wizard preflight โ€” never + raises, returns ``{"ok": False, "error": ...}`` on failure. + + ``allow_install``: when True (default) and ``azure-identity`` is not + importable, the adapter triggers the standard lazy-install path + (subject to ``security.allow_lazy_installs``) before probing. The + install failure is surfaced as the diagnostic error when it fails. + Set False for hot CLI paths that should never trigger pip. + + ``azure-identity`` doesn't expose the winning inner credential as + a public field, so we report a coarse picture (env vars present, + token expiry, claims-derived tenant) rather than the credential + class name. Users wanting the precise class can run with + ``AZURE_LOG_LEVEL=DEBUG``. + """ + info: Dict[str, Any] = {"ok": False} + if not has_azure_identity_installed(): + if not allow_install: + info["error"] = "azure-identity not installed" + info["hint"] = ( + "pip install azure-identity (or rely on lazy install at " + "first use)" + ) + return info + try: + _require_azure_identity() + except ImportError as exc: + info["error"] = str(exc) or "azure-identity not installed" + info["hint"] = ( + "pip install azure-identity manually, or enable lazy " + "installs (security.allow_lazy_installs: true in " + "config.yaml)." + ) + return info + + if config is None: + effective_scope = (scope or "").strip() or SCOPE_AI_AZURE_DEFAULT + config = EntraIdentityConfig(scope=effective_scope, **overrides) + + info["scope"] = config.scope + # Tenant / authority / service-principal config flow through the + # standard ``AZURE_*`` env vars; surface them below. + if os.environ.get("AZURE_TENANT_ID", "").strip(): + info["tenant_id_env"] = os.environ["AZURE_TENANT_ID"].strip() + + # Surface which env-var sources are present without minting yet. + env_sources = [] + if os.environ.get("AZURE_FEDERATED_TOKEN_FILE", "").strip(): + env_sources.append("WorkloadIdentityCredential (AZURE_FEDERATED_TOKEN_FILE)") + if (os.environ.get("AZURE_CLIENT_ID", "").strip() + and os.environ.get("AZURE_CLIENT_SECRET", "").strip() + and os.environ.get("AZURE_TENANT_ID", "").strip()): + env_sources.append("EnvironmentCredential (client secret)") + if os.environ.get("IDENTITY_ENDPOINT", "").strip() or os.environ.get("MSI_ENDPOINT", "").strip(): + env_sources.append("ManagedIdentityCredential (IDENTITY_ENDPOINT)") + info["env_sources"] = env_sources + + # Now try minting. + result: Dict[str, Any] = {} + + def _probe() -> None: + try: + credential = build_credential(config) + tok = credential.get_token(config.scope) + result["token"] = tok + except Exception as exc: + result["error"] = str(exc) + + thread = threading.Thread(target=_probe, daemon=True) + thread.start() + thread.join(timeout=max(0.01, timeout_seconds)) + if thread.is_alive(): + info["error"] = f"Token probe timed out after {timeout_seconds:.0f}s" + info["hint"] = ( + "DefaultAzureCredential can be slow when the token service is unreachable " + "or when az login state is stale. Try `az login` or set " + "AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET." + ) + return info + + if "error" in result: + info["error"] = result["error"] + return info + + token = result.get("token") + if token is None: + info["error"] = "credential chain exhausted" + return info + + info["ok"] = True + info["expires_on"] = getattr(token, "expires_on", None) + return info + + +# --------------------------------------------------------------------------- +# Consumer-side helpers โ€” split by purpose to prevent accidental token +# minting in logging / cache-key / dashboard paths. +# --------------------------------------------------------------------------- + + +def is_token_provider(value: Any) -> bool: + """Return True when ``value`` is a callable Entra token provider. + + Used at the seams where a consumer must decide between + string-API-key semantics and bearer-callable semantics. + """ + return callable(value) and not isinstance(value, str) + + +def materialize_bearer_for_http(value: Any) -> str: + """Return a fresh Bearer JWT for a manual HTTP request. + + Only call this at sites that must construct an ``Authorization`` + header outside the OpenAI SDK (e.g. ``hermes_cli/azure_detect.py``). + Calls the callable exactly once and returns the resulting token. + + **Anthropic SDK integration:** the Anthropic Python SDK does not + accept a ``Callable[[], str]`` for ``auth_token``. Instead, + :func:`build_bearer_http_client` returns an ``httpx.Client`` whose + request event hook calls this function and rewrites the + ``Authorization`` header per request โ€” and that client is passed to + the Anthropic SDK via ``http_client=...``. See + :func:`agent.anthropic_adapter.build_anthropic_client` for the + consumer. + + Raises ``ValueError`` if ``value`` is not a callable token provider + or non-empty string. + """ + if is_token_provider(value): + token = value() + if not isinstance(token, str) or not token: + raise ValueError("token provider returned empty value") + return token + if isinstance(value, str) and value: + return value + raise ValueError("no usable api_key / token provider") + + +def build_bearer_http_client(token_provider: Callable[[], str], **httpx_kwargs: Any) -> Any: + """Return an ``httpx.Client`` that mints a fresh Entra bearer JWT + per outbound request. + + The Anthropic SDK (โ‰ค 0.86.0 at the time of writing) stores + ``api_key`` / ``auth_token`` as static strings and computes the + ``Authorization`` header at construction time. To get per-request + token refresh (the Microsoft-recommended Foundry pattern for + callable bearer providers), we install an httpx ``request`` event + hook on a custom client and pass that client to the SDK via + ``http_client=...``. The hook: + + 1. Calls :func:`materialize_bearer_for_http` to mint a fresh JWT + (azure-identity caches internally โ€” this is cheap when the + cached token is still valid). + 2. Strips any pre-set ``Authorization`` / ``api-key`` / + ``x-api-key`` headers the SDK may have added (avoids + conflicting auth values). + 3. Sets ``Authorization: Bearer ``. + + ``token_provider`` must be a zero-arg callable returning a string โ€” + typically the result of :func:`build_token_provider`. + + ``httpx_kwargs`` are forwarded verbatim to ``httpx.Client(...)`` so + callers can attach a ``timeout``, ``transport``, ``proxy``, etc. + + Raises ``ImportError`` if ``httpx`` is not installed (it is a + transitive dependency of both ``openai`` and ``anthropic`` SDKs, so + in practice always available when this helper is reached). + """ + if not is_token_provider(token_provider): + raise ValueError( + "build_bearer_http_client requires a zero-arg callable " + "token provider" + ) + + try: + import httpx + except ImportError as exc: # pragma: no cover โ€” httpx ships with openai/anthropic + raise ImportError( + "httpx is required for Entra ID bearer auth on Microsoft Foundry " + "Anthropic-style endpoints. It is normally a transitive " + "dependency of the openai/anthropic SDKs." + ) from exc + + def _inject_bearer(request: "httpx.Request") -> None: + try: + token = materialize_bearer_for_http(token_provider) + except ValueError as exc: + # Token provider failed (chain exhausted, token service unreachable, + # az login expired, etc.). Strip any auth headers the SDK + # may have set โ€” including our own placeholder sentinel + # ``entra-id-bearer-via-http-hook`` from + # ``_build_anthropic_client_with_bearer_hook`` โ€” so the + # outbound request hits Azure with NO Authorization rather + # than with the placeholder. Azure returns a clean 401 + # "missing auth" that is easier to diagnose than a 401 + # against the sentinel string, and the sentinel never + # appears in upstream access logs. + # + # Log at WARNING (not DEBUG) so the misconfiguration is + # visible at default log levels. + logger.warning( + "Bearer hook: Entra ID token provider returned empty (%s) " + "โ€” stripping Authorization headers. Azure will respond 401. " + "Run `hermes doctor` or `az login` to recover.", + exc, + ) + for header_name in ("Authorization", "authorization", "Api-Key", "api-key", "X-Api-Key", "x-api-key"): + request.headers.pop(header_name, None) + return + for header_name in ("Authorization", "authorization", "Api-Key", "api-key", "X-Api-Key", "x-api-key"): + request.headers.pop(header_name, None) + request.headers["Authorization"] = f"Bearer {token}" + + return httpx.Client( + event_hooks={"request": [_inject_bearer]}, + **httpx_kwargs, + ) + + +__all__ = [ + "EntraIdentityConfig", + "SCOPE_AI_AZURE_DEFAULT", + "build_bearer_http_client", + "build_credential", + "build_token_provider", + "describe_active_credential", + "has_azure_identity_credentials", + "has_azure_identity_installed", + "is_token_provider", + "materialize_bearer_for_http", + "reset_credential_cache", +] diff --git a/agent/background_review.py b/agent/background_review.py index 83292029c6..5488da08de 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -112,6 +112,12 @@ _SKILL_REVIEW_PROMPT = ( "skill that governs that task needs to carry the lesson.\n\n" "If you notice two existing skills that overlap, note it in your " "reply โ€” the background curator handles consolidation at scale.\n\n" + "Protected skills (DO NOT edit these):\n" + " โ€ข Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n" + " โ€ข Hub-installed skills (installed via 'hermes skills install').\n" + " โ€ข Pinned skills (marked via 'hermes curator pin').\n" + "If the only skills that need updating are protected, say\n" + "'Nothing to save.' and stop.\n\n" "Do NOT capture (these become persistent self-imposed constraints " "that bite you later when the environment changes):\n" " โ€ข Environment-dependent failures: missing binaries, fresh-install " @@ -189,6 +195,12 @@ _COMBINED_REVIEW_PROMPT = ( "should carry user-preference lessons when relevant.\n\n" "If you notice overlapping existing skills, mention it โ€” the " "background curator handles consolidation.\n\n" + "Protected skills (DO NOT edit these):\n" + " โ€ข Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n" + " โ€ข Hub-installed skills (installed via 'hermes skills install').\n" + " โ€ข Pinned skills (marked via 'hermes curator pin').\n" + "If the only skills that need updating are protected, say\n" + "'Nothing to save.' and stop.\n\n" "Do NOT capture as skills (these become persistent self-imposed " "constraints that bite you later when the environment changes):\n" " โ€ข Environment-dependent failures: missing binaries, fresh-install " diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index ee5b957bf2..2e0caebcbe 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -291,10 +291,17 @@ def build_api_kwargs(agent, api_messages: list) -> dict: # in tool schemas (HTTP 400 "Invalid arguments passed to the model"). # Most commonly hit when MCP-derived tools carry JSON Schema validation # keywords through. Strip them before building kwargs. See #27197. + # It also rejects ``enum`` values containing ``/`` (HuggingFace IDs + # like ``Qwen/Qwen3.5-0.8B`` shipped by MCP servers) โ€” same 400 with + # the same opaque message; strip those enums too. if is_xai_responses: try: - from tools.schema_sanitizer import strip_pattern_and_format + from tools.schema_sanitizer import ( + strip_pattern_and_format, + strip_slash_enum, + ) tools_for_api, _ = strip_pattern_and_format(tools_for_api) + tools_for_api, _ = strip_slash_enum(tools_for_api) except Exception as exc: logger.warning( "%sโš ๏ธ Failed to sanitize tool schemas for xAI: %s", @@ -866,9 +873,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool # the fallback activation drops to 128K even when config says 204800. if hasattr(agent, 'context_compressor') and agent.context_compressor: from agent.model_metadata import get_model_context_length + # ``agent.api_key`` may be callable (Entra ID); the + # context-length resolver expects a string for live + # probes. Foundry typically resolves via config/static + # catalogs anyway, so coerce defensively. + _fb_ctx_api_key = agent.api_key if isinstance(agent.api_key, str) else "" fb_context_length = get_model_context_length( agent.model, base_url=agent.base_url, - api_key=agent.api_key, provider=agent.provider, + api_key=_fb_ctx_api_key, provider=agent.provider, config_context_length=getattr(agent, "_config_context_length", None), custom_providers=getattr(agent, "_custom_providers", None), ) @@ -876,7 +888,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool model=agent.model, context_length=fb_context_length, base_url=agent.base_url, - api_key=getattr(agent, "api_key", ""), + api_key=getattr(agent, "api_key", ""), # callable preserved โ†’ call_llm provider=agent.provider, ) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index e7a14faf51..a0a0b32ad1 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -378,7 +378,7 @@ class ContextCompressor(ContextEngine): model: str, context_length: int, base_url: str = "", - api_key: str = "", + api_key: Any = "", provider: str = "", api_mode: str = "", ) -> None: @@ -415,6 +415,7 @@ class ContextCompressor(ContextEngine): config_context_length: int | None = None, provider: str = "", api_mode: str = "", + abort_on_summary_failure: bool = False, ): self.model = model self.base_url = base_url @@ -426,6 +427,11 @@ class ContextCompressor(ContextEngine): self.protect_last_n = protect_last_n self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80)) self.quiet_mode = quiet_mode + # When True, summary-generation failure aborts compression entirely + # (returns messages unchanged, sets _last_compress_aborted=True). + # When False (default = historical behavior), insert a static + # "summary unavailable" placeholder and drop the middle window. + self.abort_on_summary_failure = abort_on_summary_failure self.context_length = get_model_context_length( model, base_url=base_url, api_key=api_key, @@ -478,6 +484,12 @@ class ContextCompressor(ContextEngine): # (gateway hygiene, /compress) can surface a visible warning. self._last_summary_dropped_count: int = 0 self._last_summary_fallback_used: bool = False + # When summary generation fails we now ABORT compression entirely + # and return the original messages unchanged instead of dropping + # the middle window with a static placeholder. Callers inspect + # this flag to know "compression was attempted but aborted, freeze + # the chat until the user manually retries via /compress". + self._last_compress_aborted: bool = False # When a user-configured summary model fails and we recover by # retrying on the main model, record the failure so gateway / # CLI callers can still warn the user even though compression @@ -1371,7 +1383,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Main compression entry point # ------------------------------------------------------------------ - def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None) -> List[Dict[str, Any]]: + def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, focus_topic: str = None, force: bool = False) -> List[Dict[str, Any]]: """Compress conversation messages by summarizing middle turns. Algorithm: @@ -1389,6 +1401,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio provided, the summariser will prioritise preserving information related to this topic and be more aggressive about compressing everything else. Inspired by Claude Code's ``/compact``. + force: If True, clear any active summary-failure cooldown before + running so a manual ``/compress`` can retry immediately after + an auto-compression abort. Auto-compress callers pass False. """ # Reset per-call summary failure state โ€” callers inspect these fields # after compress() returns to decide whether to surface a warning. @@ -1397,6 +1412,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio self._last_summary_error = None self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None + self._last_compress_aborted = False + + # Manual /compress (force=True) bypasses the failure cooldown so the + # user can retry immediately after an auto-compress abort. Without + # this, /compress would silently no-op for 30-60s after a failure. + if force and self._summary_failure_cooldown_until > 0.0: + self._summary_failure_cooldown_until = 0.0 n_messages = len(messages) # Only need head + 3 tail messages minimum (token budget decides the real tail size) _min_for_compress = self._protect_head_size(messages) + 3 + 1 @@ -1472,6 +1494,32 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Phase 3: Generate structured summary summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic) + # If summary generation failed, behavior splits on + # ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure): + # True โ†’ ABORT compression entirely. Return messages unchanged + # and set _last_compress_aborted=True so callers can warn + # the user and stop the auto-compress retry loop. + # False โ†’ Fall through to the legacy fallback path below: insert + # a static "summary unavailable" placeholder and drop the + # middle window. Records _last_summary_fallback_used / + # _last_summary_dropped_count for gateway hygiene to + # surface a warning. + # Default is False (historical behavior). + if not summary and self.abort_on_summary_failure: + n_skipped = compress_end - compress_start + self._last_summary_dropped_count = 0 # nothing actually dropped + self._last_summary_fallback_used = False + self._last_compress_aborted = True + if not self.quiet_mode: + logger.warning( + "Summary generation failed โ€” aborting compression " + "(compression.abort_on_summary_failure=true). " + "%d message(s) preserved unchanged. Conversation is " + "frozen until the next /compress or /new.", + n_skipped, + ) + return messages + # Phase 4: Assemble compressed message list compressed = [] for i in range(compress_start): @@ -1486,7 +1534,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio ) compressed.append(msg) - # If LLM summary failed, insert a static fallback so the model + # Legacy fallback path: LLM summary failed and abort_on_summary_failure + # is False (the default). Insert a static placeholder so the model # knows context was lost rather than silently dropping everything. if not summary: if not self.quiet_mode: diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index bc70623997..a3a9ba1d6f 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -103,7 +103,15 @@ def check_compression_model_feasibility(agent: Any) -> None: return aux_base_url = str(getattr(client, "base_url", "")) - aux_api_key = str(getattr(client, "api_key", "")) + # ``client.api_key`` may be a callable (Azure Foundry Entra ID + # bearer provider). The context-length resolver chain expects a + # string, but it only needs a key for live catalogue probes + # (provider model lists). For Entra clients the model-metadata + # chain still resolves via models.dev + hardcoded family + # fallbacks, which don't require auth โ€” pass empty string rather + # than minting a bearer JWT just to look up a context length. + _raw_aux_key = getattr(client, "api_key", "") + aux_api_key = "" if (callable(_raw_aux_key) and not isinstance(_raw_aux_key, str)) else str(_raw_aux_key or "") aux_context = get_model_context_length( aux_model, @@ -248,6 +256,7 @@ def compress_context( approx_tokens: Optional[int] = None, task_id: str = "default", focus_topic: Optional[str] = None, + force: bool = False, ) -> Tuple[list, str]: """Compress conversation context and split the session in SQLite. @@ -260,10 +269,31 @@ def compress_context( focus_topic: Optional focus string for guided compression โ€” the summariser will prioritise preserving information related to this topic. Inspired by Claude Code's ``/compact ``. + force: If True, bypass any active summary-failure cooldown. Set + by the manual ``/compress`` slash command so users can retry + immediately after an auto-compress abort. Auto-compress + callers use the default ``False``. Returns: - ``(compressed_messages, new_system_prompt)`` tuple. + ``(compressed_messages, new_system_prompt)`` tuple. When + compression aborts (aux LLM failed to produce a usable summary), + returns the original messages unchanged and the existing system + prompt โ€” the session is NOT rotated. Callers should detect the + no-op via ``len(returned) == len(input)`` and stop the retry loop. """ + # Lazy feasibility check โ€” run the auxiliary-provider probe + context + # length lookup just-in-time on the first compression attempt instead of + # at AIAgent.__init__. Saves ~400ms cold off every short session that + # never reaches the threshold (the vast majority of ``chat -q`` runs). + # The check itself sets ``agent._compression_warning`` so the + # status-callback replay machinery still emits the warning to the user + # the first time it would matter. + if not getattr(agent, "_compression_feasibility_checked", True): + try: + check_compression_model_feasibility(agent) + finally: + agent._compression_feasibility_checked = True + _pre_msg_count = len(messages) logger.info( "context compression started: session=%s messages=%d tokens=~%s model=%s focus=%r", @@ -283,12 +313,31 @@ def compress_context( pass try: - compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens, focus_topic=focus_topic) + compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens, focus_topic=focus_topic, force=force) except TypeError: # Plugin context engine with strict signature that doesn't accept - # focus_topic โ€” fall back to calling without it. + # focus_topic / force โ€” fall back to calling without them. compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens) + # If compression aborted (aux LLM failed to produce a usable summary) + # the compressor returns the input messages unchanged. Surface the + # error to the user, skip the session-rotation work entirely (no + # session has logically ended), and let auto-compress callers detect + # the no-op via len(returned) == len(input). + if getattr(agent.context_compressor, "_last_compress_aborted", False): + _err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error" + if getattr(agent, "_last_compression_summary_warning", None) != _err: + agent._last_compression_summary_warning = _err + agent._emit_warning( + f"โš  Compression aborted: {_err}. " + "No messages were dropped โ€” conversation continues unchanged. " + "Run /compress to retry, or /new to start a fresh session." + ) + _existing_sp = getattr(agent, "_cached_system_prompt", None) + if not _existing_sp: + _existing_sp = agent._build_system_prompt(system_message) + return messages, _existing_sp + summary_error = getattr(agent.context_compressor, "_last_summary_error", None) if summary_error: if getattr(agent, "_last_compression_summary_warning", None) != summary_error: diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index d3d47a5a10..41eb2d730f 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1807,7 +1807,11 @@ def run_conversation( # that survives message/tool sanitization (#6843). _credential_sanitized = False _raw_key = getattr(agent, "api_key", None) or "" - if _raw_key: + # Entra ID bearer providers are callables โ€” their + # minted JWTs are always ASCII, so no sanitization + # is needed (and ``_strip_non_ascii`` would crash + # on a callable input). + if _raw_key and isinstance(_raw_key, str): _clean_key = _strip_non_ascii(_raw_key) if _clean_key != _raw_key: agent.api_key = _clean_key @@ -2080,15 +2084,26 @@ def run_conversation( ): anthropic_auth_retry_attempted = True from agent.anthropic_adapter import _is_oauth_token + from agent.azure_identity_adapter import is_token_provider if agent._try_refresh_anthropic_client_credentials(): print(f"{agent.log_prefix}๐Ÿ” Anthropic credentials refreshed after 401. Retrying request...") continue # Credential refresh didn't help โ€” show diagnostic info key = agent._anthropic_api_key - auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)" print(f"{agent.log_prefix}๐Ÿ” Anthropic 401 โ€” authentication failed.") - print(f"{agent.log_prefix} Auth method: {auth_method}") - print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if key and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)") + if is_token_provider(key): + # Azure Foundry Entra ID โ€” the bearer token is + # minted per-request by an httpx event hook on a + # custom http_client passed to the SDK. The 401 + # means Azure rejected the JWT (RBAC role missing, + # az login expired, IMDS unreachable, etc.). + print(f"{agent.log_prefix} Auth method: Microsoft Entra ID (httpx event hook)") + print(f"{agent.log_prefix} Run `hermes doctor` for credential-chain diagnostics, or") + print(f"{agent.log_prefix} `az login` if your developer session expired.") + else: + auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)" + print(f"{agent.log_prefix} Auth method: {auth_method}") + print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if isinstance(key, str) and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)") print(f"{agent.log_prefix} Troubleshooting:") from hermes_constants import display_hermes_home as _dhh_fn _dhh = _dhh_fn() @@ -2317,7 +2332,7 @@ def run_conversation( # still recover. See _pool_may_recover_from_rate_limit # for the single-credential-pool and CloudCode-quota # exceptions. Fixes #11314 and #13636. - pool_may_recover = _pool_may_recover_from_rate_limit( + pool_may_recover = _ra()._pool_may_recover_from_rate_limit( agent._credential_pool, provider=agent.provider, base_url=getattr(agent, "base_url", None), diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index f1bff1a719..b24ddbef5d 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -636,7 +636,10 @@ class CopilotACPClient: block_error = get_read_block_error(str(path)) if block_error: raise PermissionError(block_error) - content = path.read_text() if path.exists() else "" + try: + content = path.read_text() + except FileNotFoundError: + content = "" line = params.get("line") limit = params.get("limit") if isinstance(line, int) and line > 1: diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 98dbaf3083..9a5cc20fe6 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -10,7 +10,7 @@ import time import uuid import re from dataclasses import dataclass, fields, replace -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple from hermes_constants import OPENROUTER_BASE_URL @@ -797,6 +797,13 @@ class CredentialPool: except Exception as wexc: logger.debug("Failed to write refreshed token to credentials file: %s", wexc) elif self.provider == "openai-codex": + # Adopt fresher tokens from auth.json before spending the + # refresh_token โ€” single-use tokens consumed by another Hermes + # process sharing the same auth.json singleton would otherwise + # trigger ``refresh_token_reused`` on the next POST. + synced = self._sync_codex_entry_from_auth_store(entry) + if synced is not entry: + entry = synced refreshed = auth_mod.refresh_codex_oauth_pure( entry.access_token, entry.refresh_token, @@ -907,6 +914,116 @@ class CredentialPool: self._replace_entry(synced, updated) self._persist() return updated + # Terminal error: auth.json has no newer tokens โ€” the stored + # refresh_token is dead. Clear it from auth.json so the next + # session does not re-seed the same revoked credentials, and + # remove all singleton-seeded (loopback_pkce) entries from the + # in-memory pool. Mirrors the Nous quarantine path above. + if auth_mod._is_terminal_xai_oauth_refresh_error(exc): + logger.debug( + "xAI OAuth refresh token is terminally invalid; clearing local token state" + ) + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "xai-oauth") or {} + if isinstance(state, dict): + tokens = state.get("tokens") or {} + if isinstance(tokens, dict): + store_refresh = str(tokens.get("refresh_token") or "").strip() + entry_refresh = str(entry.refresh_token or "").strip() + if not store_refresh or store_refresh == entry_refresh: + tokens.pop("access_token", None) + tokens.pop("refresh_token", None) + state["tokens"] = tokens + state["last_auth_error"] = { + "provider": "xai-oauth", + "code": getattr(exc, "code", "unknown"), + "message": str(exc), + "reason": "credential_pool_refresh_failure", + "relogin_required": True, + "at": datetime.now(timezone.utc).isoformat(), + } + _save_provider_state(auth_store, "xai-oauth", state) + _save_auth_store(auth_store) + except Exception as clear_exc: + logger.debug( + "Failed to clear terminal xAI OAuth state: %s", clear_exc + ) + self._entries = [ + item for item in self._entries + if item.source != "loopback_pkce" + ] + if self._current_id == entry.id: + self._current_id = None + self._persist() + return None + # For openai-codex: same race as xAI/nous โ€” another Hermes process + # may have consumed the refresh token between our proactive sync + # and the HTTP call. Re-check auth.json and adopt the fresh tokens + # if they have rotated since. + if self.provider == "openai-codex": + synced = self._sync_codex_entry_from_auth_store(entry) + if synced.refresh_token != entry.refresh_token: + logger.debug( + "Codex OAuth refresh failed but auth.json has newer tokens โ€” adopting" + ) + updated = replace( + synced, + last_status=STATUS_OK, + last_status_at=None, + last_error_code=None, + last_error_reason=None, + last_error_message=None, + last_error_reset_at=None, + ) + self._replace_entry(synced, updated) + self._persist() + return updated + # Terminal error: auth.json has no newer tokens โ€” the stored + # refresh_token is dead. Clear it from auth.json so the next + # session does not re-seed the same revoked credentials, and + # remove all singleton-seeded (device_code) entries from the + # in-memory pool. Mirrors the xAI and Nous quarantine paths. + if auth_mod._is_terminal_codex_oauth_refresh_error(exc): + logger.debug( + "Codex OAuth refresh token is terminally invalid; clearing local token state" + ) + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "openai-codex") or {} + if isinstance(state, dict): + tokens = state.get("tokens") or {} + if isinstance(tokens, dict): + store_refresh = str(tokens.get("refresh_token") or "").strip() + entry_refresh = str(entry.refresh_token or "").strip() + if not store_refresh or store_refresh == entry_refresh: + tokens.pop("access_token", None) + tokens.pop("refresh_token", None) + state["tokens"] = tokens + state["last_auth_error"] = { + "provider": "openai-codex", + "code": getattr(exc, "code", "unknown"), + "message": str(exc), + "reason": "credential_pool_refresh_failure", + "relogin_required": True, + "at": datetime.now(timezone.utc).isoformat(), + } + _save_provider_state(auth_store, "openai-codex", state) + _save_auth_store(auth_store) + except Exception as clear_exc: + logger.debug( + "Failed to clear terminal Codex OAuth state: %s", clear_exc + ) + self._entries = [ + item for item in self._entries + if item.source != "device_code" + ] + if self._current_id == entry.id: + self._current_id = None + self._persist() + return None # For nous: another process may have consumed the refresh token # between our proactive sync and the HTTP call. Re-sync from # auth.json and adopt the fresh tokens if available. diff --git a/agent/error_classifier.py b/agent/error_classifier.py index d29a2e34ac..42eb42d680 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -510,6 +510,35 @@ def classify_api_error( should_compress=False, ) + # xAI Grok subscription entitlement errors. + # + # xAI returns "You have either run out of available resources or do not + # have an active Grok subscription" through two distinct code paths: + # + # โ€ข HTTP 403 โ€” status_code is set; _classify_by_status (step 2) routes + # it to FailoverReason.auth correctly, and _is_entitlement_failure + # then prevents the credential-refresh loop. + # + # โ€ข SSE ``type=error`` frame โ€” surfaced as _StreamErrorEvent with + # status_code=None. _classify_by_status is skipped entirely, and + # "grok subscription" / "out of available resources" appear in none + # of the message-pattern lists below. Without this guard the error + # falls through to FailoverReason.unknown (retryable=True), burning + # max_retries before the agent stops โ€” and _is_entitlement_failure + # is never called because it only runs under FailoverReason.auth. + # + # Both X Premium+ and SuperGrok subscribers hit this path when their + # subscription tier does not cover the requested model or feature. + if ( + "do not have an active grok subscription" in error_msg + or ("out of available resources" in error_msg and "grok" in error_msg) + ): + return _result( + FailoverReason.auth, + retryable=False, + should_fallback=True, + ) + # โ”€โ”€ 2. HTTP status code classification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if status_code is not None: diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 7eda64fba4..7954713908 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -91,10 +91,12 @@ class StreamingContextScrubber: def __init__(self) -> None: self._in_span: bool = False self._buf: str = "" + self._at_block_boundary: bool = True def reset(self) -> None: self._in_span = False self._buf = "" + self._at_block_boundary = True def feed(self, text: str) -> str: """Return the visible portion of ``text`` after scrubbing. @@ -121,19 +123,22 @@ class StreamingContextScrubber: buf = buf[idx + len(self._CLOSE_TAG):] self._in_span = False else: - idx = buf.lower().find(self._OPEN_TAG) + idx = self._find_boundary_open_tag(buf) if idx == -1: # No open tag โ€” hold back a potential partial open tag - held = self._max_partial_suffix(buf, self._OPEN_TAG) + held = ( + self._max_pending_open_suffix(buf) + or self._max_partial_suffix(buf, self._OPEN_TAG) + ) if held: - out.append(buf[:-held]) + self._append_visible(out, buf[:-held]) self._buf = buf[-held:] else: - out.append(buf) + self._append_visible(out, buf) return "".join(out) # Emit text before the tag, enter span if idx > 0: - out.append(buf[:idx]) + self._append_visible(out, buf[:idx]) buf = buf[idx + len(self._OPEN_TAG):] self._in_span = True @@ -169,6 +174,55 @@ class StreamingContextScrubber: return i return 0 + def _find_boundary_open_tag(self, buf: str) -> int: + """Find an opening fence only when it starts a block-like span.""" + buf_lower = buf.lower() + search_start = 0 + while True: + idx = buf_lower.find(self._OPEN_TAG, search_start) + if idx == -1: + return -1 + if self._is_block_boundary(buf, idx) and self._has_block_opener_suffix(buf, idx): + return idx + search_start = idx + 1 + + def _max_pending_open_suffix(self, buf: str) -> int: + """Hold a complete boundary tag until the following char confirms it.""" + if not buf.lower().endswith(self._OPEN_TAG): + return 0 + idx = len(buf) - len(self._OPEN_TAG) + if not self._is_block_boundary(buf, idx): + return 0 + return len(self._OPEN_TAG) + + def _has_block_opener_suffix(self, buf: str, idx: int) -> bool: + after_idx = idx + len(self._OPEN_TAG) + if after_idx >= len(buf): + return False + return buf[after_idx] in "\r\n" + + def _is_block_boundary(self, buf: str, idx: int) -> bool: + if idx == 0: + return self._at_block_boundary + preceding = buf[:idx] + last_newline = preceding.rfind("\n") + if last_newline == -1: + return self._at_block_boundary and preceding.strip() == "" + return preceding[last_newline + 1:].strip() == "" + + def _append_visible(self, out: list[str], text: str) -> None: + if not text: + return + out.append(text) + self._update_block_boundary(text) + + def _update_block_boundary(self, text: str) -> None: + last_newline = text.rfind("\n") + if last_newline != -1: + self._at_block_boundary = text[last_newline + 1:].strip() == "" + else: + self._at_block_boundary = self._at_block_boundary and text.strip() == "" + def build_memory_context_block(raw_context: str) -> str: """Wrap prefetched memory in a fenced block with system note.""" diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 0db33e1cb3..9c36d205ac 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -206,7 +206,12 @@ KANBAN_GUIDANCE = ( "files outside it unless the task explicitly asks.\n" "3. **Heartbeat on long operations.** Call `kanban_heartbeat(note=...)` " "every few minutes during long subprocesses (training, encoding, crawling). " - "Skip heartbeats for short tasks.\n" + "Skip heartbeats for short tasks. **If your task may run longer than 1 hour, " + "you MUST call `kanban_heartbeat` at least once an hour** โ€” the dispatcher " + "reclaims tasks running past `kanban.dispatch_stale_timeout_seconds` " + "(default 4 hours) when no heartbeat has arrived in the last hour. A " + "reclaim re-queues the task as `ready` without penalty (no failure counter " + "tick), but you lose your current run's progress.\n" "4. **Block on genuine ambiguity.** If you need a human decision you cannot " "infer (missing credentials, UX choice, paywalled source, peer output you " "need first), call `kanban_block(reason=\"...\")` and stop. Don't guess. " @@ -268,7 +273,7 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = ( # Model name substrings that trigger tool-use enforcement guidance. # Add new patterns here when a model family needs explicit steering. -TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm") +TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm", "qwen", "deepseek") # OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes # where GPT models abandon work on partial results, skip prerequisite lookups, diff --git a/agent/redact.py b/agent/redact.py index c6643304a9..1beb10450f 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -103,6 +103,7 @@ _PREFIX_PATTERNS = [ r"hsk-[A-Za-z0-9]{10,}", # Hindsight API key r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key r"brv_[A-Za-z0-9]{10,}", # ByteRover API key + r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key ] # ENV assignment patterns: KEY=value where KEY contains a secret-like name @@ -320,6 +321,15 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F patterns when the text is known to be source code (e.g. MAX_TOKENS=*** constants, "apiKey": "test" fixtures). Prefix patterns, auth headers, private keys, DB connstrings, JWTs, and URL secrets are still redacted. + + Performance: each regex pattern is gated behind a cheap substring + pre-check (e.g. ``"=" in text`` for ENV assignments, ``"://" in text`` + for URLs, ``"eyJ" in text`` for JWTs). On a typical hermes log line + (no secrets) this drops the 13-pattern scan from ~5.6us to ~1.8us per + record (-68%). The pre-checks are conservative โ€” false positives + still run the full regex, which then doesn't match. False negatives + are impossible because every regex requires the gated substring to + match. """ if text is None: return None @@ -330,68 +340,122 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F if not (force or _REDACT_ENABLED): return text - # Known prefixes (sk-, ghp_, etc.) - text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text) + # Known prefixes (sk-, ghp_, etc.) โ€” gate on substring presence + if _has_known_prefix_substring(text): + text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text) # ENV assignments: OPENAI_API_KEY=*** (skip for code files โ€” false positives) if not code_file: - def _redact_env(m): - name, quote, value = m.group(1), m.group(2), m.group(3) - return f"{name}={quote}{_mask_token(value)}{quote}" - text = _ENV_ASSIGN_RE.sub(_redact_env, text) + if "=" in text: + def _redact_env(m): + name, quote, value = m.group(1), m.group(2), m.group(3) + return f"{name}={quote}{_mask_token(value)}{quote}" + text = _ENV_ASSIGN_RE.sub(_redact_env, text) # JSON fields: "apiKey": "***" (skip for code files โ€” false positives) - def _redact_json(m): - key, value = m.group(1), m.group(2) - return f'{key}: "{_mask_token(value)}"' - text = _JSON_FIELD_RE.sub(_redact_json, text) + if ":" in text and '"' in text: + def _redact_json(m): + key, value = m.group(1), m.group(2) + return f'{key}: "{_mask_token(value)}"' + text = _JSON_FIELD_RE.sub(_redact_json, text) - # Authorization headers - text = _AUTH_HEADER_RE.sub( - lambda m: m.group(1) + _mask_token(m.group(2)), - text, - ) + # Authorization headers โ€” _AUTH_HEADER_RE is "Authorization: Bearer ..." + # case-insensitive, so "uthorization" is the cheapest substring gate that + # covers both "Authorization" and "authorization" without a casefold(). + if "uthorization" in text or "UTHORIZATION" in text: + text = _AUTH_HEADER_RE.sub( + lambda m: m.group(1) + _mask_token(m.group(2)), + text, + ) - # Telegram bot tokens - def _redact_telegram(m): - prefix = m.group(1) or "" - digits = m.group(2) - return f"{prefix}{digits}:***" - text = _TELEGRAM_RE.sub(_redact_telegram, text) + # Telegram bot tokens โ€” pattern requires ":" with digits prefix + if ":" in text: + def _redact_telegram(m): + prefix = m.group(1) or "" + digits = m.group(2) + return f"{prefix}{digits}:***" + text = _TELEGRAM_RE.sub(_redact_telegram, text) # Private key blocks - text = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", text) + if "BEGIN" in text and "-----" in text: + text = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", text) # Database connection string passwords - text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text) + if "://" in text: + text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text) # JWT tokens (eyJ... โ€” base64-encoded JSON headers) - text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text) + if "eyJ" in text: + text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text) # URL userinfo (http(s)://user:pass@host) โ€” redact for non-DB schemes. # DB schemes are handled above by _DB_CONNSTR_RE. - text = _redact_url_userinfo(text) + if "://" in text: + text = _redact_url_userinfo(text) - # URL query params containing opaque tokens (?access_token=โ€ฆ&code=โ€ฆ) - text = _redact_url_query_params(text) + # URL query params containing opaque tokens (?access_token=โ€ฆ&code=โ€ฆ) + if "?" in text: + text = _redact_url_query_params(text) # Form-urlencoded bodies (only triggers on clean k=v&k=v inputs). - text = _redact_form_body(text) + if "&" in text and "=" in text: + text = _redact_form_body(text) # Discord user/role mentions (<@snowflake_id>) - text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text) + if "<@" in text: + text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text) # E.164 phone numbers (Signal, WhatsApp) - def _redact_phone(m): - phone = m.group(1) - if len(phone) <= 8: - return phone[:2] + "****" + phone[-2:] - return phone[:4] + "****" + phone[-4:] - text = _SIGNAL_PHONE_RE.sub(_redact_phone, text) + if "+" in text: + def _redact_phone(m): + phone = m.group(1) + if len(phone) <= 8: + return phone[:2] + "****" + phone[-2:] + return phone[:4] + "****" + phone[-4:] + text = _SIGNAL_PHONE_RE.sub(_redact_phone, text) return text +# Substrings used to gate ``_PREFIX_RE`` execution. If none of these appear in +# the input string, the prefix regex cannot match anything, so we skip it. +# False positives are fine (they just run the regex, which then matches +# nothing) โ€” the bound is "no false negatives" and that holds because every +# pattern in ``_PREFIX_PATTERNS`` has at least one of these as a literal +# substring of its leading characters. +# +# Derived automatically from ``_PREFIX_PATTERNS`` at module load time so a +# future PR that adds a new prefix to the regex list can't silently break +# the screen. + +def _extract_literal_prefix(pattern: str) -> str: + """Return the leading literal characters of a regex pattern. + + Stops at the first regex metacharacter (``[``, ``(``, ``\\``, ``.``, + ``?``, ``*``, ``+``, ``|``, ``{``, ``^``, ``$``). Returns the literal + that any match of the pattern MUST contain as a substring, so the + pre-screen never produces false negatives. + """ + meta = "[(\\.?*+|{^$" + for i, ch in enumerate(pattern): + if ch in meta: + return pattern[:i] + return pattern + + +_PREFIX_SUBSTRINGS = tuple( + _extract_literal_prefix(p) for p in _PREFIX_PATTERNS +) + + +def _has_known_prefix_substring(text: str) -> bool: + """Return True if ``text`` contains any known credential prefix substring. + + Used as a cheap pre-check before invoking the expensive ``_PREFIX_RE``. + """ + return any(p in text for p in _PREFIX_SUBSTRINGS) + + class RedactingFormatter(logging.Formatter): """Log formatter that redacts secrets from all log messages.""" diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 79d494d7dc..4e2b2ddd7c 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -632,7 +632,10 @@ def _locked_update_approvals() -> Iterator[Dict[str, Any]]: yield data save_allowlist(data) finally: - fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN) + try: + fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN) + except (OSError, IOError): + pass def _prompt_and_record( diff --git a/agent/skill_bundles.py b/agent/skill_bundles.py new file mode 100644 index 0000000000..10836b359f --- /dev/null +++ b/agent/skill_bundles.py @@ -0,0 +1,410 @@ +"""Skill bundles โ€” aliases that load multiple skills under one slash command. + +A skill bundle is a small YAML file that names a set of skills to load +together. Invoking ``/`` from the CLI or gateway loads every +referenced skill's full content into a single user message, the same way +``/`` does โ€” but for N skills at once. + +Storage +------- +Bundles live in ``~/.hermes/skill-bundles/*.yaml`` (and the equivalent +profile-aware directory under ``HERMES_HOME``). Each file looks like:: + + name: backend-dev + description: Backend feature work โ€” code review, testing, PR workflow. + skills: + - github-code-review + - test-driven-development + - github-pr-workflow + instruction: | + Optional extra guidance to inject above the skill bodies. + +The file's stem is treated as a fallback name when ``name:`` is absent, so +dropping a YAML into the directory is enough to register a new bundle. + +Conflict resolution +------------------- +If a bundle and a skill share the same slash name, the bundle wins. The +slash command dispatch checks bundles first, then falls back to skills. +This is the intended behavior โ€” a user who names a bundle ``research`` +explicitly wants ``/research`` to mean their bundle, not whatever skill +happens to share the slug. + +Public API +---------- +- :func:`get_skill_bundles` โ€” return ``{"/slug": bundle_info}`` +- :func:`resolve_bundle_command_key` โ€” map a user-typed command to its slug +- :func:`build_bundle_invocation_message` โ€” produce the full user message +- :func:`reload_bundles` โ€” re-scan disk and return a diff +- :func:`list_bundles` โ€” return rich info for display (``hermes bundles``) +- :func:`save_bundle` / :func:`delete_bundle` โ€” file-level operations +""" + +from __future__ import annotations + +import logging +import os +import re +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import yaml + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + +# Slug normalization โ€” matches agent/skill_commands.py so a bundle and a +# skill called "Foo Bar" both resolve to "/foo-bar". +_BUNDLE_INVALID_CHARS = re.compile(r"[^a-z0-9-]") +_BUNDLE_MULTI_HYPHEN = re.compile(r"-{2,}") + +_bundles_cache: Dict[str, Dict[str, Any]] = {} +_bundles_cache_mtime: Optional[float] = None + + +def _bundles_dir() -> Path: + """Return the canonical bundles directory under HERMES_HOME. + + Honors ``HERMES_BUNDLES_DIR`` for tests; falls back to + ``/skill-bundles``. + """ + override = os.environ.get("HERMES_BUNDLES_DIR") + if override: + return Path(override).expanduser() + return get_hermes_home() / "skill-bundles" + + +def _slugify(name: str) -> str: + cmd = name.lower().replace(" ", "-").replace("_", "-") + cmd = _BUNDLE_INVALID_CHARS.sub("", cmd) + cmd = _BUNDLE_MULTI_HYPHEN.sub("-", cmd).strip("-") + return cmd + + +def _iter_bundle_files() -> List[Path]: + base = _bundles_dir() + if not base.exists(): + return [] + files: List[Path] = [] + for ext in ("*.yaml", "*.yml"): + files.extend(sorted(base.glob(ext))) + return files + + +def _max_mtime(files: List[Path]) -> float: + """Highest mtime across the bundle files plus the dir itself. + + Watching the directory mtime catches deletions; watching individual + files catches edits. Together they're a cheap freshness check. + """ + base = _bundles_dir() + mtimes = [] + if base.exists(): + try: + mtimes.append(base.stat().st_mtime) + except OSError: + pass + for f in files: + try: + mtimes.append(f.stat().st_mtime) + except OSError: + continue + return max(mtimes) if mtimes else 0.0 + + +def _load_bundle_file(path: Path) -> Optional[Dict[str, Any]]: + """Parse a single bundle YAML file. Returns ``None`` on any error. + + Errors are logged at WARNING level. We don't raise โ€” a broken bundle + shouldn't take down slash command discovery. + """ + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + logger.warning("Could not read bundle %s: %s", path, exc) + return None + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as exc: + logger.warning("Invalid YAML in bundle %s: %s", path, exc) + return None + if not isinstance(data, dict): + logger.warning("Bundle %s is not a mapping; skipping", path) + return None + + name = str(data.get("name") or path.stem).strip() + if not name: + logger.warning("Bundle %s has no name; skipping", path) + return None + + skills = data.get("skills") or [] + if not isinstance(skills, list) or not skills: + logger.warning("Bundle %s has no skills list; skipping", path) + return None + skills = [str(s).strip() for s in skills if str(s).strip()] + if not skills: + logger.warning("Bundle %s has empty skills list; skipping", path) + return None + + description = str(data.get("description") or "").strip() + instruction = str(data.get("instruction") or "").strip() + + slug = _slugify(name) + if not slug: + logger.warning("Bundle %s yielded empty slug; skipping", path) + return None + + return { + "name": name, + "slug": slug, + "description": description or f"Load {len(skills)} skills as a bundle", + "skills": skills, + "instruction": instruction, + "path": str(path), + } + + +def scan_bundles() -> Dict[str, Dict[str, Any]]: + """Scan the bundles directory and rebuild the cache. + + Returns the same mapping as :func:`get_skill_bundles` โ€” ``"/slug"`` โ†’ + bundle info dict. Later bundles with a duplicate slug are skipped with + a warning (first wins, alphabetical order). + """ + global _bundles_cache, _bundles_cache_mtime + files = _iter_bundle_files() + out: Dict[str, Dict[str, Any]] = {} + for f in files: + info = _load_bundle_file(f) + if not info: + continue + key = f"/{info['slug']}" + if key in out: + logger.warning( + "Duplicate bundle slug %s from %s; keeping %s", + key, f, out[key]["path"], + ) + continue + out[key] = info + _bundles_cache = out + _bundles_cache_mtime = _max_mtime(files) + return out + + +def get_skill_bundles() -> Dict[str, Dict[str, Any]]: + """Return the current bundle mapping, rescanning when disk changed. + + Cheap to call repeatedly: only rescans when the bundles directory or + any bundle file's mtime is newer than the cached snapshot. + """ + files = _iter_bundle_files() + current_mtime = _max_mtime(files) + if not _bundles_cache or _bundles_cache_mtime != current_mtime: + scan_bundles() + return _bundles_cache + + +def resolve_bundle_command_key(command: str) -> Optional[str]: + """Resolve a user-typed command to its canonical bundle slash key. + + Hyphens and underscores are treated interchangeably to mirror the + skill-command behavior (Telegram converts hyphens to underscores in + bot command names). + """ + if not command: + return None + cmd_key = f"/{command.replace('_', '-')}" + return cmd_key if cmd_key in get_skill_bundles() else None + + +def reload_bundles() -> Dict[str, Any]: + """Re-scan the bundles directory and return a diff. + + Mirrors :func:`agent.skill_commands.reload_skills` so callers can use + the same display logic. Returns a dict with ``added``, ``removed``, + ``unchanged``, and ``total`` keys. + """ + def _snapshot(cmds: Dict[str, Dict[str, Any]]) -> Dict[str, str]: + return {k.lstrip("/"): (v or {}).get("description", "") for k, v in cmds.items()} + + before = _snapshot(_bundles_cache) + new = scan_bundles() + after = _snapshot(new) + + added_names = sorted(set(after) - set(before)) + removed_names = sorted(set(before) - set(after)) + unchanged = sorted(set(after) & set(before)) + + return { + "added": [{"name": n, "description": after[n]} for n in added_names], + "removed": [{"name": n, "description": before[n]} for n in removed_names], + "unchanged": unchanged, + "total": len(after), + } + + +def list_bundles() -> List[Dict[str, Any]]: + """Return a sorted list of bundle info dicts for display.""" + bundles = get_skill_bundles() + return sorted(bundles.values(), key=lambda b: b["slug"]) + + +def build_bundle_invocation_message( + cmd_key: str, + user_instruction: str = "", + task_id: str | None = None, +) -> Optional[Tuple[str, List[str], List[str]]]: + """Build the user message content for a bundle slash command invocation. + + Returns ``(message, loaded_skill_names, missing_skill_names)`` or + ``None`` if the bundle wasn't found. + + A bundle that references skills the user doesn't have installed still + loads โ€” the agent gets a note about which ones were skipped. This is + the same forgiving stance ``build_preloaded_skills_prompt`` uses for + ``-s`` CLI preloading. + """ + bundles = get_skill_bundles() + info = bundles.get(cmd_key) + if not info: + return None + + # Late import to avoid pulling tools/* at module import time and to + # keep skill_bundles cheap to import in test environments. + from agent.skill_commands import _load_skill_payload, _build_skill_message + + loaded_names: List[str] = [] + missing: List[str] = [] + skill_blocks: List[str] = [] + seen: set[str] = set() + + bundle_name = info["name"] + skills = info["skills"] + extra_instruction = info.get("instruction") or "" + + for skill_id in skills: + identifier = (skill_id or "").strip() + if not identifier or identifier in seen: + continue + seen.add(identifier) + + loaded = _load_skill_payload(identifier, task_id=task_id) + if not loaded: + missing.append(identifier) + continue + loaded_skill, skill_dir, skill_name = loaded + + try: + from tools.skill_usage import bump_use + bump_use(skill_name) + except Exception: + pass + + activation_note = ( + f'[Loaded as part of the "{bundle_name}" skill bundle.]' + ) + skill_blocks.append( + _build_skill_message( + loaded_skill, + skill_dir, + activation_note, + session_id=task_id, + ) + ) + loaded_names.append(skill_name) + + if not skill_blocks: + return None + + # Header โ€” tells the agent this is a bundle, lists the skills, and + # provides any author-supplied instruction. + header_lines = [ + f'[IMPORTANT: The user has invoked the "{bundle_name}" skill bundle, ' + f"loading {len(loaded_names)} skills together. Treat every skill below " + "as active guidance for this turn.]", + "", + f"Bundle: {bundle_name}", + f"Skills loaded: {', '.join(loaded_names)}", + ] + if missing: + header_lines.append(f"Skills missing (skipped): {', '.join(missing)}") + if extra_instruction: + header_lines.extend(["", f"Bundle instruction: {extra_instruction}"]) + if user_instruction: + header_lines.extend( + ["", f"User instruction: {user_instruction}"] + ) + + header = "\n".join(header_lines) + return ("\n\n".join([header, *skill_blocks]), loaded_names, missing) + + +# --------------------------------------------------------------------------- +# File-level CRUD helpers โ€” used by `hermes bundles` CLI subcommand. +# --------------------------------------------------------------------------- + + +def bundle_path_for(name: str) -> Path: + """Return the canonical filesystem path for a bundle name.""" + slug = _slugify(name) + if not slug: + raise ValueError(f"Bundle name {name!r} normalizes to an empty slug") + return _bundles_dir() / f"{slug}.yaml" + + +def save_bundle( + name: str, + skills: List[str], + description: str = "", + instruction: str = "", + overwrite: bool = False, +) -> Path: + """Write a bundle to disk and invalidate the cache. + + Raises ``FileExistsError`` if the target exists and ``overwrite`` is + False. Raises ``ValueError`` if the inputs are unusable. + """ + name = (name or "").strip() + if not name: + raise ValueError("Bundle name is required") + cleaned_skills = [str(s).strip() for s in skills if str(s).strip()] + if not cleaned_skills: + raise ValueError("Bundle must reference at least one skill") + + path = bundle_path_for(name) + if path.exists() and not overwrite: + raise FileExistsError(f"Bundle already exists at {path}") + + path.parent.mkdir(parents=True, exist_ok=True) + payload: Dict[str, Any] = {"name": name, "skills": cleaned_skills} + if description: + payload["description"] = description + if instruction: + payload["instruction"] = instruction + + path.write_text( + yaml.safe_dump(payload, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + scan_bundles() # refresh cache + return path + + +def delete_bundle(name: str) -> Path: + """Delete a bundle by name. Returns the deleted path. + + Raises ``FileNotFoundError`` if the bundle doesn't exist. + """ + path = bundle_path_for(name) + if not path.exists(): + raise FileNotFoundError(f"No bundle at {path}") + path.unlink() + scan_bundles() + return path + + +def get_bundle(name: str) -> Optional[Dict[str, Any]]: + """Look up a bundle by name (slug-normalized).""" + slug = _slugify(name) + return get_skill_bundles().get(f"/{slug}") diff --git a/agent/skill_preprocessing.py b/agent/skill_preprocessing.py index b95d1ddda8..2f8015c443 100644 --- a/agent/skill_preprocessing.py +++ b/agent/skill_preprocessing.py @@ -79,6 +79,14 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str: return f"[inline-shell timeout after {timeout}s: {command}]" except FileNotFoundError: return "[inline-shell error: bash not found]" + except RuntimeError as exc: + # tests/conftest.py installs a live-system guard that blocks real + # os.kill on out-of-tree PIDs. subprocess.run(timeout=...) may trip + # that guard while trying to clean up the timed-out shell; treat that + # as the same timeout outcome instead of surfacing the guard error. + if "live-system guard: blocked os.kill" in str(exc): + return f"[inline-shell timeout after {timeout}s: {command}]" + return f"[inline-shell error: {exc}]" except Exception as exc: return f"[inline-shell error: {exc}]" diff --git a/agent/system_prompt.py b/agent/system_prompt.py index a9815a2f2f..bc29c9ef89 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -111,8 +111,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # Kanban worker/orchestrator lifecycle โ€” only present when the # dispatcher spawned this process (kanban_show check_fn gates on # HERMES_KANBAN_TASK env var). Normal chat sessions never see - # this block. - if "kanban_show" in agent.valid_tool_names: + # this block. Resolved once at __init__ (see _kanban_worker_guidance). + _kanban_guidance = getattr(agent, "_kanban_worker_guidance", None) + if _kanban_guidance: + tool_guidance.append(_kanban_guidance) + elif _kanban_guidance is None and "kanban_show" in agent.valid_tool_names: + # Fallback for code paths that bypass agent_init (rare). tool_guidance.append(KANBAN_GUIDANCE) if tool_guidance: stable_parts.append(" ".join(tool_guidance)) diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index 30aa8869db..789371edfa 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -317,6 +317,19 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]: return msg +def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict: + """Build a tool-result message dict with both the OpenAI-format ``name`` + field (required by the wire format and provider adapters) and the internal + ``tool_name`` field (written to the session DB messages table).""" + return { + "role": "tool", + "name": name, + "tool_name": name, + "content": content, + "tool_call_id": tool_call_id, + } + + __all__ = [ "_NEVER_PARALLEL_TOOLS", "_PARALLEL_SAFE_TOOLS", @@ -333,4 +346,5 @@ __all__ = [ "_extract_file_mutation_targets", "_extract_error_preview", "_trajectory_normalize_msg", + "make_tool_result_message", ] diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 12bc725513..b161b507e8 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -35,6 +35,7 @@ from agent.tool_dispatch_helpers import ( _is_multimodal_tool_result, _multimodal_text_summary, _append_subdir_hint_to_multimodal, + make_tool_result_message, ) from tools.terminal_tool import ( _get_approval_callback, @@ -74,12 +75,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe if agent._interrupt_requested: print(f"{agent.log_prefix}โšก Interrupt: skipping {num_tools} tool call(s)") for tc in tool_calls: - messages.append({ - "role": "tool", - "name": tc.function.name, - "content": f"[Tool execution cancelled โ€” {tc.function.name} was skipped due to user interrupt]", - "tool_call_id": tc.id, - }) + messages.append(make_tool_result_message( + tc.function.name, + f"[Tool execution cancelled โ€” {tc.function.name} was skipped due to user interrupt]", + tc.id, + )) return # โ”€โ”€ Parse args + pre-execution bookkeeping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -443,13 +443,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # image tool result never poisons canonical session history. # String results pass through unchanged. _tool_content = agent._tool_result_content_for_active_model(name, function_result) - tool_msg = { - "role": "tool", - "name": name, - "content": _tool_content, - "tool_call_id": tc.id, - } - messages.append(tool_msg) + messages.append(make_tool_result_message(name, _tool_content, tc.id)) # โ”€โ”€ Per-tool /steer drain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Same as the sequential path: drain between each collected @@ -864,13 +858,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # Unwrap _multimodal dicts to an OpenAI-style content list # (see parallel path for rationale). String results pass through. _tool_content = agent._tool_result_content_for_active_model(function_name, function_result) - tool_msg = { - "role": "tool", - "name": function_name, - "content": _tool_content, - "tool_call_id": tool_call.id - } - messages.append(tool_msg) + messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id)) # โ”€โ”€ Per-tool /steer drain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Drain pending steer BETWEEN individual tool calls so the @@ -892,13 +880,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe agent._vprint(f"{agent.log_prefix}โšก Interrupt: skipping {remaining} remaining tool call(s)", force=True) for skipped_tc in assistant_message.tool_calls[i:]: skipped_name = skipped_tc.function.name - skip_msg = { - "role": "tool", - "name": skipped_name, - "content": f"[Tool execution skipped โ€” {skipped_name} was not started. User sent a new message]", - "tool_call_id": skipped_tc.id - } - messages.append(skip_msg) + messages.append(make_tool_result_message( + skipped_name, + f"[Tool execution skipped โ€” {skipped_name} was not started. User sent a new message]", + skipped_tc.id, + )) break if agent.tool_delay > 0 and i < len(assistant_message.tool_calls): diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index 5a9ddd507b..0332796922 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -336,10 +336,7 @@ class ToolCallGuardrailController: return ToolGuardrailDecision( action="warn", code="same_tool_failure_warning", - message=( - f"{tool_name} has failed {same_count} times this turn. " - "This looks like a loop; change approach before retrying." - ), + message=_tool_failure_recovery_hint(tool_name, same_count), tool_name=tool_name, count=same_count, signature=signature, @@ -406,6 +403,26 @@ def append_toolguard_guidance(result: str, decision: ToolGuardrailDecision) -> s return (result or "") + suffix +def _tool_failure_recovery_hint(tool_name: str, count: int) -> str: + """Action-oriented guidance for recovering from repeated tool failures.""" + common = ( + f"{tool_name} has failed {count} times this turn. This looks like a loop. " + "Do not switch to text-only replies; keep using tools, but diagnose before retrying. " + "First inspect the latest error/output and verify your assumptions. " + ) + if tool_name == "terminal": + return common + ( + "For terminal failures, run a small diagnostic such as `pwd && ls -la` " + "in the same tool, then try an absolute path, a simpler command, a different " + "working directory, or a different tool such as read_file/write_file/patch." + ) + return common + ( + "Try different arguments, a narrower query/path, an absolute path when relevant, " + "or a different tool that can make progress. If the blocker is external, report " + "the blocker after one diagnostic attempt instead of repeating the same failing path." + ) + + def _coerce_args(args: Mapping[str, Any] | None) -> Mapping[str, Any]: return args if isinstance(args, Mapping) else {} diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html index e420ce6dba..fe7cda519d 100644 --- a/apps/dashboard/index.html +++ b/apps/dashboard/index.html @@ -3,7 +3,10 @@ - + Hermes Agent - Dashboard diff --git a/apps/dashboard/package-lock.json b/apps/dashboard/package-lock.json index 143bb35794..d617db6e5b 100644 --- a/apps/dashboard/package-lock.json +++ b/apps/dashboard/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@hermes/shared": "file:../shared", - "@nous-research/ui": "^0.10.0", + "@nous-research/ui": "0.14.0", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", "@tailwindcss/vite": "^4.2.1", @@ -20,9 +20,11 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flag-icons": "^7.5.0", "gsap": "^3.15.0", "leva": "^0.10.1", "lucide-react": "^0.577.0", + "motion": "^12.38.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.14.1", @@ -69,9 +71,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "dev": true, "license": "MIT", "engines": { @@ -240,9 +242,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -1090,17 +1092,17 @@ } }, "node_modules/@nous-research/ui": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.10.0.tgz", - "integrity": "sha512-gzB7rjzW4F9C1YkILR9EvCk6Ul6cWhqEeb2HzuRJK4NiC1gHeQ2D2Pr+15qbMghV4SuTLJmwLSLvbH76nRA5Jw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.14.0.tgz", + "integrity": "sha512-tfpE6jGOxE5oVBab/dTSepOudy/+Xep3gJ6NCFriYJvdtQBGXcqsi4mCaVPiNNaS/ZFf4/10dnl/oJTb6DtLKg==", "license": "MIT", "dependencies": { - "@nanostores/react": "^1.0.0", + "@nanostores/react": "^1.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "nanostores": "^1.0.1", - "sanitize-html": "^2.16.0", - "tailwind-merge": "^3.3.1", + "nanostores": "^1.3.0", + "sanitize-html": "^2.17.4", + "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "unicode-animations": "^1.0.3" }, @@ -1109,6 +1111,7 @@ "@react-three/fiber": "^9.4.0", "gsap": "^3.13.0", "leva": "^0.10.1", + "motion": "^12.38.0", "react": "^19.0.0", "react-dom": "^19.0.0", "three": "^0.180.0" @@ -1784,9 +1787,9 @@ "license": "MIT" }, "node_modules/@react-three/fiber": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", - "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", + "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.17.8", @@ -1839,9 +1842,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", - "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -1852,9 +1855,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", - "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -1865,9 +1868,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", - "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -1878,9 +1881,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", - "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -1891,9 +1894,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", - "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -1904,9 +1907,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", - "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -1917,12 +1920,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", - "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1930,12 +1936,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", - "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1943,12 +1952,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", - "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1956,12 +1968,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", - "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1969,12 +1984,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", - "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1982,12 +2000,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", - "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1995,12 +2016,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", - "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2008,12 +2032,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", - "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2021,12 +2048,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", - "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2034,12 +2064,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", - "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2047,12 +2080,15 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", - "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2060,12 +2096,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", - "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2073,12 +2112,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", - "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2086,9 +2128,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", - "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -2099,9 +2141,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", - "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -2112,9 +2154,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", - "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -2125,9 +2167,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", - "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -2138,9 +2180,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", - "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -2151,9 +2193,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", - "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -2173,47 +2215,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -2227,9 +2269,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -2243,9 +2285,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -2259,9 +2301,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -2275,9 +2317,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -2291,12 +2333,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2307,12 +2352,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2323,12 +2371,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2339,12 +2390,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2355,9 +2409,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2372,10 +2426,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -2384,17 +2438,17 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", + "version": "1.10.0", "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", + "version": "1.10.0", "inBundle": true, "license": "MIT", "optional": true, @@ -2403,7 +2457,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", + "version": "1.2.1", "inBundle": true, "license": "MIT", "optional": true, @@ -2412,18 +2466,20 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", + "version": "1.1.4", "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { @@ -2442,9 +2498,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -2458,9 +2514,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -2474,14 +2530,14 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -2533,9 +2589,10 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -2546,9 +2603,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2556,9 +2613,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2590,17 +2647,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", - "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/type-utils": "8.59.1", - "@typescript-eslint/utils": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2613,7 +2670,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.1", + "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2629,16 +2686,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", - "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "engines": { @@ -2654,14 +2711,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", - "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.1", - "@typescript-eslint/types": "^8.59.1", + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "engines": { @@ -2676,14 +2733,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", - "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1" + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2694,9 +2751,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", - "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", "dev": true, "license": "MIT", "engines": { @@ -2711,15 +2768,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", - "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2736,9 +2793,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", - "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", "dev": true, "license": "MIT", "engines": { @@ -2750,16 +2807,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", - "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.1", - "@typescript-eslint/tsconfig-utils": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/visitor-keys": "8.59.1", + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2788,9 +2845,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -2817,9 +2874,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -2830,16 +2887,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", - "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.1", - "@typescript-eslint/types": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1" + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2854,13 +2911,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", - "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3065,9 +3122,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.24", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", - "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "version": "2.10.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", + "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3163,9 +3220,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -3705,6 +3762,12 @@ "node": ">=12" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3834,16 +3897,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.344", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", - "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "version": "1.5.360", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.360.tgz", + "integrity": "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.21.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz", + "integrity": "sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -4213,6 +4276,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4243,6 +4312,33 @@ "node": ">=0.10.0" } }, + "node_modules/framer-motion": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.39.0.tgz", + "integrity": "sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.39.0", + "motion-utils": "^12.39.0", + "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/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4290,9 +4386,9 @@ } }, "node_modules/globals": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", - "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -4529,9 +4625,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -4613,6 +4709,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, "node_modules/leva": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", @@ -4803,6 +4908,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4823,6 +4931,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4843,6 +4954,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4863,6 +4977,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5020,6 +5137,47 @@ "node": ">=0.10.0" } }, + "node_modules/motion": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.39.0.tgz", + "integrity": "sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.39.0", + "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/motion-dom": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.39.0.tgz", + "integrity": "sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5028,9 +5186,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -5068,9 +5226,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", "dev": true, "license": "MIT" }, @@ -5191,9 +5349,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -5210,7 +5368,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5250,18 +5408,18 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-colorful": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", - "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz", + "integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==", "license": "MIT", "peerDependencies": { "react": ">=16.8.0", @@ -5269,15 +5427,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.6" } }, "node_modules/react-dropzone": { @@ -5314,9 +5472,9 @@ } }, "node_modules/react-router": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", - "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -5336,12 +5494,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", - "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", "license": "MIT", "dependencies": { - "react-router": "7.14.2" + "react-router": "7.15.1" }, "engines": { "node": ">=20.0.0" @@ -5383,9 +5541,9 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", - "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -5398,34 +5556,40 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.2", - "@rollup/rollup-android-arm64": "4.60.2", - "@rollup/rollup-darwin-arm64": "4.60.2", - "@rollup/rollup-darwin-x64": "4.60.2", - "@rollup/rollup-freebsd-arm64": "4.60.2", - "@rollup/rollup-freebsd-x64": "4.60.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", - "@rollup/rollup-linux-arm-musleabihf": "4.60.2", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", - "@rollup/rollup-linux-arm64-musl": "4.60.2", - "@rollup/rollup-linux-loong64-gnu": "4.60.2", - "@rollup/rollup-linux-loong64-musl": "4.60.2", - "@rollup/rollup-linux-ppc64-gnu": "4.60.2", - "@rollup/rollup-linux-ppc64-musl": "4.60.2", - "@rollup/rollup-linux-riscv64-gnu": "4.60.2", - "@rollup/rollup-linux-riscv64-musl": "4.60.2", - "@rollup/rollup-linux-s390x-gnu": "4.60.2", - "@rollup/rollup-linux-x64-gnu": "4.60.2", - "@rollup/rollup-linux-x64-musl": "4.60.2", - "@rollup/rollup-openbsd-x64": "4.60.2", - "@rollup/rollup-openharmony-arm64": "4.60.2", - "@rollup/rollup-win32-arm64-msvc": "4.60.2", - "@rollup/rollup-win32-ia32-msvc": "4.60.2", - "@rollup/rollup-win32-x64-gnu": "4.60.2", - "@rollup/rollup-win32-x64-msvc": "4.60.2", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -5439,15 +5603,16 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz", - "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==", + "version": "2.17.4", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz", + "integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", + "launder": "^1.7.1", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } @@ -5600,9 +5765,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", - "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", "funding": { "type": "github", @@ -5610,9 +5775,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "license": "MIT" }, "node_modules/tapable": { @@ -5706,16 +5871,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", - "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.1", - "@typescript-eslint/parser": "8.59.1", - "@typescript-eslint/typescript-estree": "8.59.1", - "@typescript-eslint/utils": "8.59.1" + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5806,9 +5971,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -5926,9 +6091,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", "funding": { @@ -5949,9 +6114,9 @@ } }, "node_modules/zustand": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", - "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index ae57ab45cc..8fc25f9d03 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -4,9 +4,6 @@ "version": "0.0.0", "type": "module", "scripts": { - "sync-assets": "node scripts/sync-assets.cjs", - "predev": "npm run sync-assets", - "prebuild": "npm run sync-assets", "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", @@ -14,7 +11,7 @@ }, "dependencies": { "@hermes/shared": "file:../shared", - "@nous-research/ui": "^0.10.0", + "@nous-research/ui": "0.14.0", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", "@tailwindcss/vite": "^4.2.1", @@ -25,9 +22,11 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "flag-icons": "^7.5.0", "gsap": "^3.15.0", "leva": "^0.10.1", "lucide-react": "^0.577.0", + "motion": "^12.38.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.14.1", diff --git a/apps/dashboard/scripts/sync-assets.cjs b/apps/dashboard/scripts/sync-assets.cjs deleted file mode 100644 index ad4ba2ee4c..0000000000 --- a/apps/dashboard/scripts/sync-assets.cjs +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -/** - * Copy font and asset folders from @nous-research/ui into public/ for Vite. - * - * Locates @nous-research/ui by walking up from this script looking for - * node_modules/@nous-research/ui โ€” works whether the dep is co-located - * (non-workspace layout) or hoisted to the repo root (npm workspaces). - */ -const fs = require('node:fs') -const path = require('node:path') - -const DASHBOARD_ROOT = path.resolve(__dirname, '..') - -function locateUiPackage() { - let dir = DASHBOARD_ROOT - const { root } = path.parse(dir) - while (true) { - const candidate = path.join(dir, 'node_modules', '@nous-research', 'ui') - if (fs.existsSync(path.join(candidate, 'package.json'))) { - return candidate - } - if (dir === root) break - dir = path.dirname(dir) - } - throw new Error( - '@nous-research/ui not found. Run `npm install` from the repo root.' - ) -} - -const uiRoot = locateUiPackage() -const distRoot = path.join(uiRoot, 'dist') - -const mappings = [ - ['fonts', path.join(DASHBOARD_ROOT, 'public', 'fonts')], - ['assets', path.join(DASHBOARD_ROOT, 'public', 'ds-assets')], -] - -for (const [srcName, destPath] of mappings) { - const srcPath = path.join(distRoot, srcName) - if (!fs.existsSync(srcPath)) { - throw new Error(`Missing ${srcPath} in @nous-research/ui โ€” rebuild that package.`) - } - fs.rmSync(destPath, { recursive: true, force: true }) - fs.cpSync(srcPath, destPath, { recursive: true }) - console.log(`synced ${path.relative(DASHBOARD_ROOT, destPath)}`) -} diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 71a97113c2..987252ce0b 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -424,8 +424,8 @@ export default function App() {
-
+
@@ -588,8 +588,8 @@ export default function App() { "relative z-2 flex min-w-0 min-h-0 flex-1 flex-col", "px-3 sm:px-6", isChatRoute - ? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4" - : "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8", + ? "pb-0 pt-1 sm:pt-2 lg:pt-4" + : "pt-2 sm:pt-4 lg:pt-6", isDocsRoute && "min-h-0 flex-1", )} > @@ -597,6 +597,8 @@ export default function App() {
` from * `@nous-research/ui` without pulling in its leva / gsap / three peer deps. @@ -10,7 +12,7 @@ import { useGpuTier } from "@nous-research/ui/hooks/use-gpu-tier"; * `ThemeProvider` can repaint the stack without remounting. * * z-1 bg = `var(--background-base)`, mix-blend-mode: difference - * z-2 filler-bg jpeg, inverted, opacity 0.033, difference + * z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference * z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten * z-101 noise grain (SVG, ~55% opacity ร— `--noise-opacity-mul`, * color-dodge) โ€” gated on GPU tier @@ -58,7 +60,7 @@ export function Backdrop() { alt="" className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler" fetchPriority="low" - src="/ds-assets/filler-bg0.jpg" + src={fillerBgUrl} />
diff --git a/apps/dashboard/src/components/BottomPickSheet.tsx b/apps/dashboard/src/components/BottomPickSheet.tsx new file mode 100644 index 0000000000..1490f4090c --- /dev/null +++ b/apps/dashboard/src/components/BottomPickSheet.tsx @@ -0,0 +1,224 @@ +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { Typography } from "@/components/NouiTypography"; +import { cn } from "@/lib/utils"; + +const CLOSE_DRAG_MIN_PX = 72; +const CLOSE_DRAG_RATIO = 0.18; +const SHEET_TRANSITION_MS = 280; + +/** + * Mobile-first picker shell: fixed backdrop + bottom sheet, portaled to `body` + * so nested overflow/transform in the sidebar cannot clip menus (theme / + * language switchers). Open/close uses slide + fade; teardown is delayed until + * the exit animation finishes so animations can complete. + * + * Drag the header/handle downward to dismiss (skipped when reduced motion is on). + */ +export function BottomPickSheet({ + backdropDismissLabel = "Dismiss", + children, + onClose, + open, + title, +}: BottomPickSheetProps) { + const [renderPortal, setRenderPortal] = useState(open); + const [entered, setEntered] = useState(false); + const [dragOffsetPx, setDragOffsetPx] = useState(0); + const [dragActive, setDragActive] = useState(false); + + const closeTimerRef = useRef | null>(null); + const sheetRef = useRef(null); + const dragTrackingRef = useRef(false); + const dragStartYRef = useRef(0); + const dragOffsetRef = useRef(0); + + const reducedMotion = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const syncDragPx = (next: number) => { + dragOffsetRef.current = next; + setDragOffsetPx(next); + }; + + useEffect(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + + const ms = reducedMotion ? 0 : SHEET_TRANSITION_MS; + + let openRafId = 0; + let exitRafId = 0; + + if (open) { + openRafId = requestAnimationFrame(() => { + dragTrackingRef.current = false; + dragOffsetRef.current = 0; + setDragActive(false); + setDragOffsetPx(0); + setRenderPortal(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setEntered(true)); + }); + }); + } else { + exitRafId = requestAnimationFrame(() => { + dragTrackingRef.current = false; + setDragActive(false); + setEntered(false); + closeTimerRef.current = window.setTimeout(() => { + dragOffsetRef.current = 0; + setDragOffsetPx(0); + setRenderPortal(false); + closeTimerRef.current = null; + }, ms); + }); + } + + return () => { + cancelAnimationFrame(openRafId); + cancelAnimationFrame(exitRafId); + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }; + }, [open, reducedMotion]); + + useEffect(() => { + if (!renderPortal) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [renderPortal]); + + if (!renderPortal || typeof document === "undefined") return null; + + const durationClass = reducedMotion ? "duration-0" : "duration-[280ms]"; + + const draggingVisual = dragActive || dragOffsetPx > 0; + + const onDragPointerDown = (e: ReactPointerEvent) => { + if (reducedMotion || !entered) return; + if (e.pointerType === "mouse" && e.button !== 0) return; + + dragTrackingRef.current = true; + setDragActive(true); + dragStartYRef.current = e.clientY; + syncDragPx(0); + e.currentTarget.setPointerCapture(e.pointerId); + }; + + const onDragPointerMove = (e: ReactPointerEvent) => { + if (!dragTrackingRef.current) return; + const dy = e.clientY - dragStartYRef.current; + const next = Math.max(0, dy); + const sheetH = sheetRef.current?.offsetHeight ?? 560; + syncDragPx(Math.min(next, sheetH)); + }; + + const endDrag = (e: ReactPointerEvent) => { + if (!dragTrackingRef.current) return; + dragTrackingRef.current = false; + setDragActive(false); + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + /* already released */ + } + + const sheetH = sheetRef.current?.offsetHeight ?? 560; + const threshold = Math.max(CLOSE_DRAG_MIN_PX, sheetH * CLOSE_DRAG_RATIO); + const d = dragOffsetRef.current; + + if (d >= threshold) { + onClose(); + return; + } + syncDragPx(0); + }; + + return createPortal( +
+ - {open && ( -
setOpen(false)} + open={open} + title={sheetTitle} > - {allLocales.map(([code, meta]) => { - const selected = code === locale; - return ( - - ); - })} +
+ +
+ + )} + + {open && !useMobileSheet && ( +
+
)}
); } + +function LanguageSwitcherOptions({ + allLocales, + locale, + setLocale, + setOpen, +}: LanguageSwitcherOptionsProps) { + return ( + <> + {allLocales.map(([code, meta]) => { + const selected = code === locale; + + return ( + + ); + })} + + ); +} + +function LocaleFlagIcon({ countryCode }: LocaleFlagIconProps) { + return ( + + ); +} + +interface LanguageSwitcherOptionsProps { + allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>; + locale: Locale; + setLocale: (code: Locale) => void; + setOpen: (open: boolean) => void; +} + +interface LanguageSwitcherProps { + dropUp?: boolean; +} + +interface LocaleFlagIconProps { + countryCode: string; +} diff --git a/apps/dashboard/src/components/ModelPickerDialog.tsx b/apps/dashboard/src/components/ModelPickerDialog.tsx index d99ea09a8a..22b2cb1bce 100644 --- a/apps/dashboard/src/components/ModelPickerDialog.tsx +++ b/apps/dashboard/src/components/ModelPickerDialog.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"; import type { GatewayClient } from "@/lib/gatewayClient"; import { Check, Search, X } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; /** * Two-stage model picker modal. @@ -194,7 +195,14 @@ export function ModelPickerDialog(props: Props) { } }; - return ( + // Portal to document.body: the main dashboard column in App.tsx is + // `relative z-2`, which creates a stacking context that traps fixed + // descendants below the app sidebar (z-50). Without the portal this + // modal's z-[100] is scoped to z-2 and the sidebar covers its left + // edge โ€” visible especially in the Large theme variants where the + // larger root font widens the dialog into the sidebar's column. See + // Toast.tsx for the same pattern. + return createPortal(
e.target === e.currentTarget && onClose()} @@ -296,7 +304,8 @@ export function ModelPickerDialog(props: Props) {
-
+ , + document.body, ); } diff --git a/apps/dashboard/src/components/ThemeSwitcher.tsx b/apps/dashboard/src/components/ThemeSwitcher.tsx index 90a3d11ebd..17e0ae3d6d 100644 --- a/apps/dashboard/src/components/ThemeSwitcher.tsx +++ b/apps/dashboard/src/components/ThemeSwitcher.tsx @@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Palette, Check } from "lucide-react"; import { Button } from "@nous-research/ui/ui/components/button"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; +import { BottomPickSheet } from "@/components/BottomPickSheet"; import { Typography } from "@/components/NouiTypography"; +import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint"; import { BUILTIN_THEMES, useTheme } from "@/themes"; -import type { DashboardTheme } from "@/themes"; +import type { DashboardTheme, ThemeListEntry } from "@/themes"; import { useI18n } from "@/i18n"; import { cn } from "@/lib/utils"; @@ -17,18 +19,31 @@ import { cn } from "@/lib/utils"; * * When placed at the bottom of a container (e.g. the sidebar rail), pass * `dropUp` so the menu opens above the trigger instead of clipping below - * the viewport. + * the viewport. On viewports below the `sm` breakpoint, `dropUp` uses a + * bottom sheet portaled to `document.body` so the picker is not clipped by + * the sidebar (same idea as a responsive Drawer). */ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { const { themeName, availableThemes, setTheme } = useTheme(); const { t } = useI18n(); const [open, setOpen] = useState(false); const wrapperRef = useRef(null); + const narrowViewport = useBelowBreakpoint(640); + const useMobileSheet = Boolean(dropUp && narrowViewport); const close = useCallback(() => setOpen(false), []); useEffect(() => { if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, close]); + + useEffect(() => { + if (!open || useMobileSheet) return; const onMouseDown = (e: MouseEvent) => { if ( wrapperRef.current && @@ -37,19 +52,13 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { close(); } }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") close(); - }; document.addEventListener("mousedown", onMouseDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onMouseDown); - document.removeEventListener("keydown", onKey); - }; - }, [open, close]); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [open, close, useMobileSheet]); const current = availableThemes.find((th) => th.name === themeName); const label = current?.label ?? themeName; + const sheetTitle = t.theme?.title ?? "Theme"; return (
@@ -74,77 +83,113 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { - {open && ( + {useMobileSheet && ( + +
+ +
+
+ )} + + {open && !useMobileSheet && (
- {t.theme?.title ?? "Theme"} + {sheetTitle}
- {availableThemes.map((th) => { - const isActive = th.name === themeName; - const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition; - - return ( - { - setTheme(th.name); - close(); - }} - className="gap-3" - > - {paletteTheme ? ( - - ) : ( - - )} - -
- - {th.label} - - {th.description && ( - - {th.description} - - )} -
- - -
- ); - })} +
)}
); } +function ThemeSwitcherOptions({ + availableThemes, + close, + setTheme, + themeName, +}: ThemeSwitcherOptionsProps) { + return ( + <> + {availableThemes.map((th) => { + const isActive = th.name === themeName; + const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition; + + return ( + { + setTheme(th.name); + close(); + }} + role="option" + > + {paletteTheme ? ( + + ) : ( + + )} + +
+ + {th.label} + + {th.description && ( + + {th.description} + + )} +
+ + +
+ ); + })} + + ); +} + function ThemeSwatch({ theme }: { theme: DashboardTheme }) { const { background, midground, warmGlow } = theme.palette; return ( @@ -168,6 +213,13 @@ function PlaceholderSwatch() { ); } +interface ThemeSwitcherOptionsProps { + availableThemes: ThemeListEntry[]; + close: () => void; + setTheme: (name: string) => void; + themeName: string; +} + interface ThemeSwitcherProps { dropUp?: boolean; } diff --git a/apps/dashboard/src/contexts/PageHeaderProvider.tsx b/apps/dashboard/src/contexts/PageHeaderProvider.tsx index 4184ecb3d9..9fdd6215e3 100644 --- a/apps/dashboard/src/contexts/PageHeaderProvider.tsx +++ b/apps/dashboard/src/contexts/PageHeaderProvider.tsx @@ -35,6 +35,9 @@ export function PageHeaderProvider({ const displayTitle = titleOverride ?? defaultTitle; const isChatRoute = pathname === "/chat" || pathname === "/chat/"; + /** Env jump-nav is wide โ€” stack below title on small screens so KEYS stays readable. */ + const isEnvRoute = + pathname === "/env" || pathname.startsWith("/env/"); const value = useMemo( () => ({ @@ -51,37 +54,65 @@ export function PageHeaderProvider({
-
+

{displayTitle}

- {afterTitle} + {afterTitle ? ( +
+ {afterTitle} +
+ ) : null}
{end ? (
{end} @@ -93,6 +124,8 @@ export function PageHeaderProvider({
+ typeof window !== "undefined" ? window.matchMedia(query).matches : false, + ); + + useEffect(() => { + const mql = window.matchMedia(query); + const sync = () => setMatches(mql.matches); + sync(); + mql.addEventListener("change", sync); + return () => mql.removeEventListener("change", sync); + }, [query]); + + return matches; +} diff --git a/apps/dashboard/src/i18n/af.ts b/apps/dashboard/src/i18n/af.ts index e588a63596..f19a5b7916 100644 --- a/apps/dashboard/src/i18n/af.ts +++ b/apps/dashboard/src/i18n/af.ts @@ -654,6 +654,7 @@ export const af: Translations = { columnLabels: { triage: "Triage", todo: "Te doen", + scheduled: "Geskeduleerd", ready: "Gereed", running: "Aan die gang", blocked: "Geblokkeer", @@ -663,6 +664,7 @@ export const af: Translations = { columnHelp: { triage: "Rou idees โ€” 'n spesifiseerder sal die spesifikasie uitwerk", todo: "Wag op afhanklikhede of nie toegewys nie", + scheduled: "Wag op 'n bekende tydvertraging of geskeduleerde opvolg", ready: "Afhanklikhede is bevredig; wys 'n profiel toe om te versend", running: "Deur 'n werker geรซis โ€” in vlug", blocked: "Werker het mensinvoer aangevra", diff --git a/apps/dashboard/src/i18n/context.tsx b/apps/dashboard/src/i18n/context.tsx index 7d6fecf5c9..e31ffa6505 100644 --- a/apps/dashboard/src/i18n/context.tsx +++ b/apps/dashboard/src/i18n/context.tsx @@ -38,25 +38,26 @@ const TRANSLATIONS: Record = { // Display metadata for the language picker โ€” endonym (native name) so users // recognize their language even if they don't speak the current UI language, -// plus a flag emoji for visual scanning. Exposed as a constant so the -// LanguageSwitcher and any future settings page can share the same list. -export const LOCALE_META: Record = { - en: { name: "English", flag: "๐Ÿ‡ฌ๐Ÿ‡ง" }, - zh: { name: "็ฎ€ไฝ“ไธญๆ–‡", flag: "๐Ÿ‡จ๐Ÿ‡ณ" }, - "zh-hant": { name: "็น้ซ”ไธญๆ–‡", flag: "๐Ÿ‡น๐Ÿ‡ผ" }, - ja: { name: "ๆ—ฅๆœฌ่ชž", flag: "๐Ÿ‡ฏ๐Ÿ‡ต" }, - de: { name: "Deutsch", flag: "๐Ÿ‡ฉ๐Ÿ‡ช" }, - es: { name: "Espaรฑol", flag: "๐Ÿ‡ช๐Ÿ‡ธ" }, - fr: { name: "Franรงais", flag: "๐Ÿ‡ซ๐Ÿ‡ท" }, - tr: { name: "Tรผrkรงe", flag: "๐Ÿ‡น๐Ÿ‡ท" }, - uk: { name: "ะฃะบั€ะฐั—ะฝััŒะบะฐ", flag: "๐Ÿ‡บ๐Ÿ‡ฆ" }, - af: { name: "Afrikaans", flag: "๐Ÿ‡ฟ๐Ÿ‡ฆ" }, - ko: { name: "ํ•œ๊ตญ์–ด", flag: "๐Ÿ‡ฐ๐Ÿ‡ท" }, - it: { name: "Italiano", flag: "๐Ÿ‡ฎ๐Ÿ‡น" }, - ga: { name: "Gaeilge", flag: "๐Ÿ‡ฎ๐Ÿ‡ช" }, - pt: { name: "Portuguรชs", flag: "๐Ÿ‡ต๐Ÿ‡น" }, - ru: { name: "ะ ัƒััะบะธะน", flag: "๐Ÿ‡ท๐Ÿ‡บ" }, - hu: { name: "Magyar", flag: "๐Ÿ‡ญ๐Ÿ‡บ" }, +// plus a flag-icons sprite (ISO 3166-1 alpha-2) for visual scanning. +// Exposed as a constant so the LanguageSwitcher and any future settings page +// can share the same list. +export const LOCALE_META: Record = { + en: { name: "English", flagCountryCode: "gb" }, + zh: { name: "็ฎ€ไฝ“ไธญๆ–‡", flagCountryCode: "cn" }, + "zh-hant": { name: "็น้ซ”ไธญๆ–‡", flagCountryCode: "tw" }, + ja: { name: "ๆ—ฅๆœฌ่ชž", flagCountryCode: "jp" }, + de: { name: "Deutsch", flagCountryCode: "de" }, + es: { name: "Espaรฑol", flagCountryCode: "es" }, + fr: { name: "Franรงais", flagCountryCode: "fr" }, + tr: { name: "Tรผrkรงe", flagCountryCode: "tr" }, + uk: { name: "ะฃะบั€ะฐั—ะฝััŒะบะฐ", flagCountryCode: "ua" }, + af: { name: "Afrikaans", flagCountryCode: "za" }, + ko: { name: "ํ•œ๊ตญ์–ด", flagCountryCode: "kr" }, + it: { name: "Italiano", flagCountryCode: "it" }, + ga: { name: "Gaeilge", flagCountryCode: "ie" }, + pt: { name: "Portuguรชs", flagCountryCode: "pt" }, + ru: { name: "ะ ัƒััะบะธะน", flagCountryCode: "ru" }, + hu: { name: "Magyar", flagCountryCode: "hu" }, }; const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[]; diff --git a/apps/dashboard/src/i18n/de.ts b/apps/dashboard/src/i18n/de.ts index 28a9b59def..7826cf8856 100644 --- a/apps/dashboard/src/i18n/de.ts +++ b/apps/dashboard/src/i18n/de.ts @@ -653,6 +653,7 @@ export const de: Translations = { columnLabels: { triage: "Triage", todo: "Zu erledigen", + scheduled: "Geplant", ready: "Bereit", running: "In Bearbeitung", blocked: "Blockiert", @@ -662,6 +663,7 @@ export const de: Translations = { columnHelp: { triage: "Rohe Ideen โ€” ein Specifier wird die Spezifikation ausarbeiten", todo: "Wartet auf Abhรคngigkeiten oder ist nicht zugewiesen", + scheduled: "Wartet auf eine bekannte Verzรถgerung oder eine geplante Nachverfolgung", ready: "Abhรคngigkeiten erfรผllt; Profil zum Dispatch zuweisen", running: "Von einem Worker รผbernommen โ€” in Bearbeitung", blocked: "Worker hat um menschliche Eingabe gebeten", diff --git a/apps/dashboard/src/i18n/en.ts b/apps/dashboard/src/i18n/en.ts index 5eae3f9a14..071ffa2fec 100644 --- a/apps/dashboard/src/i18n/en.ts +++ b/apps/dashboard/src/i18n/en.ts @@ -658,6 +658,7 @@ export const en: Translations = { columnLabels: { triage: "Triage", todo: "Todo", + scheduled: "Scheduled", ready: "Ready", running: "In Progress", blocked: "Blocked", @@ -667,6 +668,7 @@ export const en: Translations = { columnHelp: { triage: "Raw ideas โ€” a specifier will flesh out the spec", todo: "Waiting on dependencies or unassigned", + scheduled: "Waiting on a known time delay or scheduled follow-up", ready: "Dependencies satisfied; assign a profile to dispatch", running: "Claimed by a worker โ€” in-flight", blocked: "Worker asked for human input", @@ -679,6 +681,8 @@ export const en: Translations = { "Archive this task? It disappears from the default board view.", confirmBlocked: "Mark this task as blocked? The worker's claim is released.", + confirmScheduled: + "Move this task to Scheduled? Use this for known time delays rather than human blockers.", completionSummary: "Completion summary for {label}. This is stored as the task result.", completionSummaryRequired: diff --git a/apps/dashboard/src/i18n/es.ts b/apps/dashboard/src/i18n/es.ts index 139a8175d4..aea83fdbd5 100644 --- a/apps/dashboard/src/i18n/es.ts +++ b/apps/dashboard/src/i18n/es.ts @@ -653,6 +653,7 @@ export const es: Translations = { columnLabels: { triage: "Clasificaciรณn", todo: "Por hacer", + scheduled: "Programado", ready: "Listo", running: "En curso", blocked: "Bloqueado", @@ -662,6 +663,7 @@ export const es: Translations = { columnHelp: { triage: "Ideas en bruto โ€” un specifier desarrollarรก la especificaciรณn", todo: "Esperando dependencias o sin asignar", + scheduled: "Esperando un retraso conocido o un seguimiento programado", ready: "Dependencias satisfechas; asigna un perfil para despachar", running: "Reclamado por un worker โ€” en ejecuciรณn", blocked: "El worker pidiรณ intervenciรณn humana", diff --git a/apps/dashboard/src/i18n/fr.ts b/apps/dashboard/src/i18n/fr.ts index 51b5ba54f1..f71273d549 100644 --- a/apps/dashboard/src/i18n/fr.ts +++ b/apps/dashboard/src/i18n/fr.ts @@ -653,6 +653,7 @@ export const fr: Translations = { columnLabels: { triage: "Triage", todo: "ร€ faire", + scheduled: "Planifiรฉ", ready: "Prรชt", running: "En cours", blocked: "Bloquรฉ", @@ -662,6 +663,7 @@ export const fr: Translations = { columnHelp: { triage: "Idรฉes brutes โ€” un specifier rรฉdigera la spรฉcification", todo: "En attente de dรฉpendances ou non assignรฉ", + scheduled: "En attente d'un dรฉlai connu ou d'un suivi planifiรฉ", ready: "Dรฉpendances satisfaites ; assignez un profil pour dispatch", running: "Rรฉclamรฉ par un worker โ€” en cours d'exรฉcution", blocked: "Le worker a demandรฉ une intervention humaine", diff --git a/apps/dashboard/src/i18n/ga.ts b/apps/dashboard/src/i18n/ga.ts index 4dc4e82343..23f5c4b55f 100644 --- a/apps/dashboard/src/i18n/ga.ts +++ b/apps/dashboard/src/i18n/ga.ts @@ -654,6 +654,7 @@ export const ga: Translations = { columnLabels: { triage: "Triรกiseรกil", todo: "Le dรฉanamh", + scheduled: "Sceidealta", ready: "Rรฉidh", running: "Ar siรบl", blocked: "Bactha", @@ -663,6 +664,7 @@ export const ga: Translations = { columnHelp: { triage: "Smaointe amha โ€” dรฉanfaidh specifier an spec a chur i bhfeidhm", todo: "Ag fanacht ar spleรกchais nรณ gan sannadh", + scheduled: "Ag fanacht ar mhoill ama atรก ar eolas nรณ ar leanรบint sceidealta", ready: "Tรก na spleรกchais sรกsaithe; sann prรณifรญl le dispatch a dhรฉanamh", running: "ร‰ilithe ag worker โ€” ar siรบl", blocked: "D'iarr an worker ionchur duine", diff --git a/apps/dashboard/src/i18n/hu.ts b/apps/dashboard/src/i18n/hu.ts index 8b492f3bb1..baea43955a 100644 --- a/apps/dashboard/src/i18n/hu.ts +++ b/apps/dashboard/src/i18n/hu.ts @@ -654,6 +654,7 @@ export const hu: Translations = { columnLabels: { triage: "Triรกzs", todo: "Tennivalรณ", + scheduled: "รœtemezett", ready: "Indulรกsra kรฉsz", running: "Folyamatban", blocked: "Blokkolva", @@ -663,6 +664,7 @@ export const hu: Translations = { columnHelp: { triage: "Nyers รถtletek โ€” egy specifier kidolgozza a specifikรกciรณt", todo: "Fรผggล‘sรฉgekre vรกr vagy nincs felelล‘se", + scheduled: "Ismert idล‘zรญtรฉsre vagy รผtemezett utรกnkรถvetรฉsre vรกr", ready: "A fรผggล‘sรฉgek teljesรผltek; rendelj hozzรก profilt az indรญtรกshoz", running: "Worker felvette โ€” folyamatban", blocked: "A worker emberi beavatkozรกst kรฉrt", diff --git a/apps/dashboard/src/i18n/it.ts b/apps/dashboard/src/i18n/it.ts index 86fce86589..71515820e6 100644 --- a/apps/dashboard/src/i18n/it.ts +++ b/apps/dashboard/src/i18n/it.ts @@ -653,6 +653,7 @@ export const it: Translations = { columnLabels: { triage: "Triage", todo: "Da fare", + scheduled: "Pianificato", ready: "Pronto", running: "In corso", blocked: "Bloccato", @@ -662,6 +663,7 @@ export const it: Translations = { columnHelp: { triage: "Idee grezze โ€” un specifier elaborerร  la specifica", todo: "In attesa di dipendenze o non assegnato", + scheduled: "In attesa di un ritardo noto o di un follow-up pianificato", ready: "Dipendenze soddisfatte; assegna un profilo per il dispatch", running: "Preso in carico da un worker โ€” in esecuzione", blocked: "Il worker ha richiesto input umano", diff --git a/apps/dashboard/src/i18n/ja.ts b/apps/dashboard/src/i18n/ja.ts index 154e11f5db..76859a1ef9 100644 --- a/apps/dashboard/src/i18n/ja.ts +++ b/apps/dashboard/src/i18n/ja.ts @@ -654,6 +654,7 @@ export const ja: Translations = { columnLabels: { triage: "ใƒˆใƒชใ‚ขใƒผใ‚ธ", todo: "ToDo", + scheduled: "ใ‚นใ‚ฑใ‚ธใƒฅใƒผใƒซๆธˆใฟ", ready: "ๆบ–ๅ‚™ๅฎŒไบ†", running: "้€ฒ่กŒไธญ", blocked: "ใƒ–ใƒญใƒƒใ‚ฏไธญ", @@ -663,6 +664,7 @@ export const ja: Translations = { columnHelp: { triage: "ๆœชๆ•ด็†ใฎใ‚ขใ‚คใƒ‡ใ‚ข โ€” ใ‚นใƒšใ‚ทใƒ•ใ‚กใ‚คใ‚ขใŒไป•ๆง˜ใ‚’่‚‰ไป˜ใ‘ใ—ใพใ™", todo: "ไพๅญ˜้–ขไฟ‚ใฎๅพ…ๆฉŸไธญใ€ใพใŸใฏๆœชๅ‰ฒใ‚Šๅฝ“ใฆ", + scheduled: "ๆ—ข็Ÿฅใฎๆ™‚้–“้…ๅปถใพใŸใฏใ‚นใ‚ฑใ‚ธใƒฅใƒผใƒซๆธˆใฟใฎใƒ•ใ‚ฉใƒญใƒผใ‚ขใƒƒใƒ—ๅพ…ใก", ready: "ไพๅญ˜้–ขไฟ‚ใฏๆบ€ใŸใ•ใ‚Œใฆใ„ใพใ™ใ€‚ใƒ‡ใ‚ฃใ‚นใƒ‘ใƒƒใƒใ™ใ‚‹ใซใฏใƒ—ใƒญใƒ•ใ‚กใ‚คใƒซใ‚’ๅ‰ฒใ‚Šๅฝ“ใฆใฆใใ ใ•ใ„", running: "ใƒฏใƒผใ‚ซใƒผใŒๅ–ๅพ—ไธญ โ€” ๅฎŸ่กŒไธญ", blocked: "ใƒฏใƒผใ‚ซใƒผใŒไบบ้–“ใฎๅ…ฅๅŠ›ใ‚’ๆฑ‚ใ‚ใฆใ„ใพใ™", diff --git a/apps/dashboard/src/i18n/ko.ts b/apps/dashboard/src/i18n/ko.ts index 4dafaeb9cd..4d34ca837f 100644 --- a/apps/dashboard/src/i18n/ko.ts +++ b/apps/dashboard/src/i18n/ko.ts @@ -654,6 +654,7 @@ export const ko: Translations = { columnLabels: { triage: "๋ถ„๋ฅ˜", todo: "ํ•  ์ผ", + scheduled: "์˜ˆ์•ฝ๋จ", ready: "์ค€๋น„๋จ", running: "์ง„ํ–‰ ์ค‘", blocked: "์ฐจ๋‹จ๋จ", @@ -663,6 +664,7 @@ export const ko: Translations = { columnHelp: { triage: "์›์‹œ ์•„์ด๋””์–ด โ€” ์ŠคํŽ˜์‹œํŒŒ์ด์–ด๊ฐ€ ์‚ฌ์–‘์„ ๊ตฌ์ฒดํ™”ํ•ฉ๋‹ˆ๋‹ค", todo: "์ข…์†์„ฑ ๋Œ€๊ธฐ ์ค‘ ๋˜๋Š” ๋ฏธ์ง€์ •", + scheduled: "์•Œ๋ ค์ง„ ์‹œ๊ฐ„ ์ง€์—ฐ ๋˜๋Š” ์˜ˆ์•ฝ๋œ ํ›„์† ์กฐ์น˜๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๋Š” ์ค‘", ready: "์ข…์†์„ฑ์ด ์ถฉ์กฑ๋จ; ๋””์ŠคํŒจ์น˜ํ•˜๋ ค๋ฉด ํ”„๋กœํ•„์„ ์ง€์ •ํ•˜์„ธ์š”", running: "์›Œ์ปค๊ฐ€ ์ ์œ  ์ค‘ โ€” ์‹คํ–‰ ์ค‘", blocked: "์›Œ์ปค๊ฐ€ ์‚ฌ๋žŒ์˜ ์ž…๋ ฅ์„ ์š”์ฒญํ•จ", diff --git a/apps/dashboard/src/i18n/pt.ts b/apps/dashboard/src/i18n/pt.ts index d32402dc92..78aec925e1 100644 --- a/apps/dashboard/src/i18n/pt.ts +++ b/apps/dashboard/src/i18n/pt.ts @@ -654,6 +654,7 @@ export const pt: Translations = { columnLabels: { triage: "Triagem", todo: "A fazer", + scheduled: "Agendado", ready: "Pronto", running: "Em curso", blocked: "Bloqueado", @@ -663,6 +664,7 @@ export const pt: Translations = { columnHelp: { triage: "Ideias em bruto โ€” um specifier vai detalhar a especificaรงรฃo", todo: "ร€ espera de dependรชncias ou sem atribuiรงรฃo", + scheduled: "ร€ espera de um atraso conhecido ou de um seguimento agendado", ready: "Dependรชncias satisfeitas; atribua um perfil para despachar", running: "Reivindicado por um worker โ€” em execuรงรฃo", blocked: "O worker pediu intervenรงรฃo humana", diff --git a/apps/dashboard/src/i18n/ru.ts b/apps/dashboard/src/i18n/ru.ts index 79a6961b25..3d94d1a226 100644 --- a/apps/dashboard/src/i18n/ru.ts +++ b/apps/dashboard/src/i18n/ru.ts @@ -654,6 +654,7 @@ export const ru: Translations = { columnLabels: { triage: "ะกะพั€ั‚ะธั€ะพะฒะบะฐ", todo: "ะš ะฒั‹ะฟะพะปะฝะตะฝะธัŽ", + scheduled: "ะ—ะฐะฟะปะฐะฝะธั€ะพะฒะฐะฝะพ", ready: "ะ“ะพั‚ะพะฒะพ ะบ ั€ะฐะฑะพั‚ะต", running: "ะ’ ั€ะฐะฑะพั‚ะต", blocked: "ะ—ะฐะฑะปะพะบะธั€ะพะฒะฐะฝะพ", @@ -663,6 +664,7 @@ export const ru: Translations = { columnHelp: { triage: "ะกั‹ั€ั‹ะต ะธะดะตะธ โ€” specifier ะฟะพะดะณะพั‚ะพะฒะธั‚ ัะฟะตั†ะธั„ะธะบะฐั†ะธัŽ", todo: "ะžะถะธะดะฐะตั‚ ะทะฐะฒะธัะธะผะพัั‚ะตะน ะธะปะธ ะฑะตะท ะธัะฟะพะปะฝะธั‚ะตะปั", + scheduled: "ะžะถะธะดะฐะตั‚ ะธะทะฒะตัั‚ะฝะพะน ะทะฐะดะตั€ะถะบะธ ะฟะพ ะฒั€ะตะผะตะฝะธ ะธะปะธ ะทะฐะฟะปะฐะฝะธั€ะพะฒะฐะฝะฝะพะณะพ ะฟั€ะพะดะพะปะถะตะฝะธั", ready: "ะ—ะฐะฒะธัะธะผะพัั‚ะธ ะฒั‹ะฟะพะปะฝะตะฝั‹; ะฝะฐะทะฝะฐั‡ัŒั‚ะต ะฟั€ะพั„ะธะปัŒ ะดะปั ะดะธัะฟะตั‚ั‡ะตั€ะธะทะฐั†ะธะธ", running: "ะ’ะทัั‚ะพ ะฒะพั€ะบะตั€ะพะผ โ€” ะฒั‹ะฟะพะปะฝัะตั‚ัั", blocked: "ะ’ะพั€ะบะตั€ ะทะฐะฟั€ะพัะธะป ะฒะผะตัˆะฐั‚ะตะปัŒัั‚ะฒะพ ั‡ะตะปะพะฒะตะบะฐ", diff --git a/apps/dashboard/src/i18n/tr.ts b/apps/dashboard/src/i18n/tr.ts index 56670424ab..a96b4bc3fb 100644 --- a/apps/dashboard/src/i18n/tr.ts +++ b/apps/dashboard/src/i18n/tr.ts @@ -654,6 +654,7 @@ export const tr: Translations = { columnLabels: { triage: "Triyaj", todo: "Yapฤฑlacak", + scheduled: "Zamanlandฤฑ", ready: "Hazฤฑr", running: "Sรผrรผyor", blocked: "Engellendi", @@ -663,6 +664,7 @@ export const tr: Translations = { columnHelp: { triage: "Ham fikirler โ€” bir specifier ลŸartnameyi detaylandฤฑracak", todo: "BaฤŸฤฑmlฤฑlฤฑklar bekleniyor veya atanmamฤฑลŸ", + scheduled: "Bilinen bir zaman gecikmesi veya zamanlanmฤฑลŸ takip bekleniyor", ready: "BaฤŸฤฑmlฤฑlฤฑklar karลŸฤฑlandฤฑ; dispatch iรงin bir profil atayฤฑn", running: "Bir worker tarafฤฑndan alฤฑndฤฑ โ€” yรผrรผtรผlรผyor", blocked: "Worker insan girdisi istedi", diff --git a/apps/dashboard/src/i18n/types.ts b/apps/dashboard/src/i18n/types.ts index 55669a4b67..3b45678f40 100644 --- a/apps/dashboard/src/i18n/types.ts +++ b/apps/dashboard/src/i18n/types.ts @@ -666,6 +666,7 @@ export interface Translations { columnLabels: { triage: string; todo: string; + scheduled: string; ready: string; running: string; blocked: string; @@ -675,6 +676,7 @@ export interface Translations { columnHelp: { triage: string; todo: string; + scheduled: string; ready: string; running: string; blocked: string; @@ -684,6 +686,7 @@ export interface Translations { confirmDone: string; confirmArchive: string; confirmBlocked: string; + confirmScheduled?: string; completionSummary: string; completionSummaryRequired: string; triagePlaceholder: string; diff --git a/apps/dashboard/src/i18n/uk.ts b/apps/dashboard/src/i18n/uk.ts index 3c3df8dae6..ddf6409271 100644 --- a/apps/dashboard/src/i18n/uk.ts +++ b/apps/dashboard/src/i18n/uk.ts @@ -654,6 +654,7 @@ export const uk: Translations = { columnLabels: { triage: "ะกะพั€ั‚ัƒะฒะฐะฝะฝั", todo: "ะ”ะพ ะฒะธะบะพะฝะฐะฝะฝั", + scheduled: "ะ—ะฐะฟะปะฐะฝะพะฒะฐะฝะพ", ready: "ะ“ะพั‚ะพะฒะพ", running: "ะฃ ั€ะพะฑะพั‚ั–", blocked: "ะ—ะฐะฑะปะพะบะพะฒะฐะฝะพ", @@ -663,6 +664,7 @@ export const uk: Translations = { columnHelp: { triage: "ะกะธั€ั– ั–ะดะตั— โ€” ัะฟะตั†ะธั„ั–ะบะฐั‚ะพั€ ะดะตั‚ะฐะปั–ะทัƒั” ัะฟะตั†ะธั„ั–ะบะฐั†ั–ัŽ", todo: "ะžั‡ั–ะบัƒั” ะฝะฐ ะทะฐะปะตะถะฝะพัั‚ั– ะฐะฑะพ ะฝะต ะฟั€ะธะทะฝะฐั‡ะตะฝะพ", + scheduled: "ะžั‡ั–ะบัƒั” ะฝะฐ ะฒั–ะดะพะผัƒ ะทะฐั‚ั€ะธะผะบัƒ ะฒ ั‡ะฐัั– ะฐะฑะพ ะทะฐะฟะปะฐะฝะพะฒะฐะฝะต ะฟั€ะพะดะพะฒะถะตะฝะฝั", ready: "ะ—ะฐะปะตะถะฝะพัั‚ั– ะทะฐะดะพะฒะพะปะตะฝั–; ะฟั€ะธะทะฝะฐั‡ั‚ะต ะฟั€ะพั„ั–ะปัŒ ะดะปั ะดะธัะฟะตั‚ั‡ะตั€ะธะทะฐั†ั–ั—", running: "ะ—ะฐั…ะพะฟะปะตะฝะพ ะฒะพั€ะบะตั€ะพะผ โ€” ัƒ ั€ะพะฑะพั‚ั–", blocked: "ะ’ะพั€ะบะตั€ ะทะฐะฟะธั‚ะฐะฒ ะฒั‚ั€ัƒั‡ะฐะฝะฝั ะปัŽะดะธะฝะธ", diff --git a/apps/dashboard/src/i18n/zh-hant.ts b/apps/dashboard/src/i18n/zh-hant.ts index 27f3a41b95..540806484d 100644 --- a/apps/dashboard/src/i18n/zh-hant.ts +++ b/apps/dashboard/src/i18n/zh-hant.ts @@ -654,6 +654,7 @@ export const zhHant: Translations = { columnLabels: { triage: "ๅพ…ๅˆ†้กž", todo: "ๅพ…่พฆ", + scheduled: "ๅทฒๆŽ’็จ‹", ready: "ๅฐฑ็ท’", running: "้€ฒ่กŒไธญ", blocked: "ๅทฒๅฐ้Ž–", @@ -663,6 +664,7 @@ export const zhHant: Translations = { columnHelp: { triage: "ๅŽŸๅง‹ๆƒณๆณ• โ€” ่ฆๆ ผๅˆถๅฎš่€…ๅฐ‡ๅฎŒๅ–„่ฆๆ ผ", todo: "็ญ‰ๅพ…็›ธไพ้ …็›ฎๆˆ–ๅฐšๆœชๆŒ‡ๆดพ", + scheduled: "็ญ‰ๅพ…ๅทฒ็Ÿฅ็š„ๆ™‚้–“ๅปถ้ฒๆˆ–ๅทฒๆŽ’็จ‹็š„ๅพŒ็บŒ่™•็†", ready: "็›ธไพ้ …็›ฎๅทฒๆปฟ่ถณ๏ผ›ๆŒ‡ๆดพ่จญๅฎšๆช”ไปฅไพฟๆŽ’็จ‹", running: "ๅทฒ่ขซๅทฅไฝœ่€…้ ˜ๅ– โ€” ๅŸท่กŒไธญ", blocked: "ๅทฅไฝœ่€…่ซ‹ๆฑ‚ไบบๅทฅ่ผธๅ…ฅ", diff --git a/apps/dashboard/src/i18n/zh.ts b/apps/dashboard/src/i18n/zh.ts index 6290c473b8..7339387edd 100644 --- a/apps/dashboard/src/i18n/zh.ts +++ b/apps/dashboard/src/i18n/zh.ts @@ -650,6 +650,7 @@ export const zh: Translations = { columnLabels: { triage: "ๅพ…ๅˆ†็ฑป", todo: "ๅพ…ๅŠž", + scheduled: "ๅทฒ่ฐƒๅบฆ", ready: "ๅฐฑ็ปช", running: "่ฟ›่กŒไธญ", blocked: "้˜ปๅกž", @@ -659,6 +660,7 @@ export const zh: Translations = { columnHelp: { triage: "ๅŽŸๅง‹ๆƒณๆณ• โ€” ่ง„่Œƒๅˆถๅฎš่€…ๅฐ†ๅฎŒๅ–„่ง„ๆ ผ", todo: "็ญ‰ๅพ…ไพ่ต–้กนๆˆ–ๆœชๅˆ†้…", + scheduled: "็ญ‰ๅพ…ๅทฒ็Ÿฅ็š„ๆ—ถ้—ดๅปถ่ฟŸๆˆ–ๅทฒ่ฐƒๅบฆ็š„่ทŸ่ฟ›", ready: "ไพ่ต–้กนๅทฒๆปก่ถณ๏ผ›ๅˆ†้…ไธ€ไธช้…็ฝฎๆ–‡ไปถไปฅไพฟ่ฐƒๅบฆ", running: "ๅทฒ่ขซๅทฅไฝœ่€…่ฎค้ข† โ€” ๆ‰ง่กŒไธญ", blocked: "ๅทฅไฝœ่€…่ฏทๆฑ‚ไบบๅทฅ่พ“ๅ…ฅ", diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 2b571b6277..b7e2ba6c57 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -138,21 +138,22 @@ export const api = { }, // Cron jobs - getCronJobs: () => fetchJSON("/api/cron/jobs"), - createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) => - fetchJSON("/api/cron/jobs", { + getCronJobs: (profile = "all") => + fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`), + createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") => + fetchJSON(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(job), }), - pauseCronJob: (id: string) => - fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }), - resumeCronJob: (id: string) => - fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }), - triggerCronJob: (id: string) => - fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }), - deleteCronJob: (id: string) => - fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }), + pauseCronJob: (id: string, profile = "default") => + fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }), + resumeCronJob: (id: string, profile = "default") => + fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }), + triggerCronJob: (id: string, profile = "default") => + fetchJSON(`/api/cron/jobs/${encodeURIComponent(id)}/trigger?profile=${encodeURIComponent(profile)}`, { method: "POST" }), + deleteCronJob: (id: string, profile = "default") => + fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }), // Profiles (minimal) getProfiles: () => @@ -553,6 +554,10 @@ export interface ModelsAnalyticsResponse { export interface CronJob { id: string; + profile?: string | null; + profile_name?: string | null; + hermes_home?: string | null; + is_default_profile?: boolean; name?: string | null; prompt?: string | null; script?: string | null; diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index e0d00fdf63..c727f0e3f7 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -1,5 +1,6 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +import "flag-icons/css/flag-icons.min.css"; import "./index.css"; import App from "./App"; import { SystemActionsProvider } from "./contexts/SystemActions"; diff --git a/apps/dashboard/src/pages/AnalyticsPage.tsx b/apps/dashboard/src/pages/AnalyticsPage.tsx index e46a1806e6..5d8ff70694 100644 --- a/apps/dashboard/src/pages/AnalyticsPage.tsx +++ b/apps/dashboard/src/pages/AnalyticsPage.tsx @@ -439,7 +439,7 @@ export default function AnalyticsPage() { ); setEnd( showTokens === false ? null : ( -
+
{PERIODS.map((p) => (
+
+ + +
+
-

- - {t.cron.scheduledJobs} ({jobs.length}) -

+
+

+ + {t.cron.scheduledJobs} ({jobs.length}) +

+ +
+ + +
+
{jobs.length === 0 && ( @@ -367,10 +433,12 @@ export default function CronPage() { const title = getJobTitle(job); const hasName = Boolean(getJobName(job)); const deliver = asText(job.deliver); + const profile = getJobProfile(job); + const jobKey = getJobKey(job); return ( - - + +
@@ -379,6 +447,7 @@ export default function CronPage() { {state} + {profileLabel(profile)} {deliver && deliver !== "local" && ( {deliver} )} @@ -436,7 +505,7 @@ export default function CronPage() { size="icon" title={t.common.delete} aria-label={t.common.delete} - onClick={() => jobDelete.requestDelete(job.id)} + onClick={() => jobDelete.requestDelete(jobKey)} > diff --git a/apps/dashboard/src/pages/EnvPage.tsx b/apps/dashboard/src/pages/EnvPage.tsx index a06df1a5e1..2e8192435f 100644 --- a/apps/dashboard/src/pages/EnvPage.tsx +++ b/apps/dashboard/src/pages/EnvPage.tsx @@ -537,13 +537,16 @@ export default function EnvPage() { document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); }; setAfterTitle( -